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(
117 manager: &crate::session::ConversationManager,
118 snapshot: &mermaid_domain::ConversationHistory,
119) {
120 let row = crate::session::session_row(manager.conversations_dir(), snapshot);
121 let _ = mermaid_runtime::with_shared_store(|store| store.sessions().upsert(row));
122}
123
124#[derive(Clone)]
125enum PersistenceJob {
126 Conversation {
127 snapshot: Box<mermaid_domain::ConversationHistory>,
128 events: Vec<mermaid_domain::SessionEvent>,
129 },
130 Compaction(Box<PendingCompactionSave>),
131}
132
133#[derive(Clone)]
134struct PendingCompactionSave {
135 record: mermaid_domain::CompactionEvent,
136 conversation: mermaid_domain::ConversationHistory,
137 events: Vec<mermaid_domain::SessionEvent>,
138 events_appended: bool,
142 task_id: Option<String>,
143}
144
145struct PersistedCompaction {
146 id: String,
147 task_id: Option<String>,
148 session_id: String,
149 archive_path: PathBuf,
150}
151
152const CHECKPOINT_EVERY_EVENTS: usize = 200;
161
162struct PersistenceState {
163 workdir: PathBuf,
164 manager: Option<crate::session::ConversationManager>,
165 blocked: HashMap<String, VecDeque<PendingCompactionSave>>,
166 dirty: HashMap<String, DirtySession>,
170 unappended: HashMap<String, Vec<mermaid_domain::SessionEvent>>,
175}
176
177struct DirtySession {
178 snapshot: mermaid_domain::ConversationHistory,
179 events_since_checkpoint: usize,
180}
181
182impl PersistenceState {
183 fn new(workdir: PathBuf) -> Self {
184 Self {
185 workdir,
186 manager: None,
187 blocked: HashMap::new(),
188 dirty: HashMap::new(),
189 unappended: HashMap::new(),
190 }
191 }
192
193 fn manager(&mut self) -> anyhow::Result<&crate::session::ConversationManager> {
194 if self.manager.is_none() {
195 self.manager = Some(crate::session::ConversationManager::new(&self.workdir)?);
196 }
197 Ok(self.manager.as_ref().expect("manager initialized"))
198 }
199
200 fn process(&mut self, job: PersistenceJob) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
205 match job {
206 PersistenceJob::Conversation { snapshot, events } => {
207 let (persisted, retried) = self.retry_blocked(&snapshot.id);
210 if retried.is_err() {
211 return (persisted, retried);
212 }
213 let saved = self.save_session(*snapshot, events);
214 (persisted, saved)
215 },
216 PersistenceJob::Compaction(save) => {
217 let conversation_id = save.conversation.id.clone();
223 self.blocked
224 .entry(conversation_id.clone())
225 .or_default()
226 .push_back(*save);
227 self.retry_blocked(&conversation_id)
228 },
229 }
230 }
231
232 fn retry_blocked(
233 &mut self,
234 conversation_id: &str,
235 ) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
236 let mut persisted = Vec::new();
237 if !self.blocked.contains_key(conversation_id) {
238 return (persisted, Ok(()));
239 }
240 if let Err(error) = self.manager() {
241 return (persisted, Err(error));
242 }
243 let manager = self.manager.as_ref().expect("manager initialized");
247 let dirty = &mut self.dirty;
248 let queue = self
249 .blocked
250 .get_mut(conversation_id)
251 .expect("checked above");
252 while let Some(save) = queue.front_mut() {
253 match Self::persist_compaction(manager, dirty, save) {
254 Ok(event) => {
258 persisted.push(event);
259 queue.pop_front();
260 },
261 Err(error) => return (persisted, Err(error)),
262 }
263 }
264 self.blocked.remove(conversation_id);
265 (persisted, Ok(()))
266 }
267
268 fn save_session(
277 &mut self,
278 snapshot: mermaid_domain::ConversationHistory,
279 events: Vec<mermaid_domain::SessionEvent>,
280 ) -> anyhow::Result<()> {
281 let id = snapshot.id.clone();
282 let mut batch = self.unappended.remove(&id).unwrap_or_default();
284 batch.extend(events);
285
286 let manager = self.manager()?;
287 if let Err(error) = manager.append_session_events(&snapshot, &batch) {
288 tracing::warn!(
289 id = %id,
290 pending = batch.len(),
291 %error,
292 "session event append failed; holding the events for the next save"
293 );
294 self.unappended.insert(id, batch);
295 return Err(error);
296 }
297 upsert_session_index(manager, &snapshot);
298
299 let entry = self
300 .dirty
301 .entry(id.clone())
302 .or_insert_with(|| DirtySession {
303 snapshot: snapshot.clone(),
304 events_since_checkpoint: 0,
305 });
306 entry.snapshot = snapshot;
307 entry.events_since_checkpoint += batch.len();
308 if entry.events_since_checkpoint >= CHECKPOINT_EVERY_EVENTS {
309 return self.write_checkpoint(&id);
310 }
311 Ok(())
312 }
313
314 fn write_checkpoint(&mut self, id: &str) -> anyhow::Result<()> {
317 let Some(dirty) = self.dirty.remove(id) else {
318 return Ok(());
319 };
320 let manager = self.manager()?;
321 if let Err(error) = manager.save_conversation(&dirty.snapshot) {
322 self.dirty.insert(id.to_string(), dirty);
326 return Err(error);
327 }
328 Ok(())
329 }
330
331 fn flush_checkpoints(&mut self) -> anyhow::Result<()> {
335 let ids: Vec<String> = self.dirty.keys().cloned().collect();
336 let mut first_error = None;
337 for id in ids {
338 if let Err(error) = self.write_checkpoint(&id) {
339 first_error.get_or_insert(error);
340 }
341 }
342 first_error.map_or(Ok(()), Err)
343 }
344
345 fn retry_all_blocked(&mut self) -> (Vec<PersistedCompaction>, anyhow::Result<()>) {
346 let ids: Vec<String> = self.blocked.keys().cloned().collect();
347 let mut persisted = Vec::new();
348 let mut first_error = None;
349 for id in ids {
350 let (events, result) = self.retry_blocked(&id);
353 persisted.extend(events);
354 if let Err(error) = result {
355 first_error.get_or_insert(error);
356 }
357 }
358 match first_error {
359 None => (persisted, Ok(())),
360 Some(error) => (persisted, Err(error)),
361 }
362 }
363
364 fn persist_compaction(
365 manager: &crate::session::ConversationManager,
366 dirty: &mut HashMap<String, DirtySession>,
367 save: &mut PendingCompactionSave,
368 ) -> anyhow::Result<PersistedCompaction> {
369 if !save.events_appended {
378 manager.append_session_events(&save.conversation, &save.events)?;
379 save.events_appended = true;
380 }
381 manager.save_conversation(&save.conversation)?;
382 upsert_session_index(manager, &save.conversation);
383 dirty.remove(&save.conversation.id);
387
388 let log_path = manager.event_log_path(&save.conversation.id);
389 let _ = mermaid_runtime::with_shared_store(|store| {
390 store.compactions().create(compaction_row(
391 &save.record,
392 &log_path,
393 save.task_id.clone(),
394 save.conversation.id.clone(),
395 ))
396 });
397
398 Ok(PersistedCompaction {
399 id: save.record.id.clone(),
400 task_id: save.task_id.clone(),
401 session_id: save.conversation.id.clone(),
402 archive_path: log_path,
403 })
404 }
405}
406
407async fn fire_compaction_hook(event: &PersistedCompaction) {
409 fire_plugin_hooks(
410 "compaction",
411 serde_json::json!({
412 "id": event.id,
413 "task_id": event.task_id,
414 "session_id": event.session_id,
415 "archive_path": event.archive_path.display().to_string(),
416 }),
417 )
418 .await;
419}
420
421pub struct EffectRunner {
424 msg_tx: MsgSender,
425 scopes: HashMap<TurnId, TurnScope>,
431 cancelled_turns: VecDeque<TurnId>,
437 detached: tokio::task::JoinSet<()>,
441 persistence_state: Arc<Mutex<PersistenceState>>,
445 persistence_tail: Option<tokio::task::JoinHandle<()>>,
446 workdir: PathBuf,
450 providers: Option<Arc<ProviderFactory>>,
455 tools: Option<Arc<ToolRegistry>>,
458 task_id: Option<String>,
460 terminal_title_enabled: bool,
464 owns_global_mcp: bool,
470 approval: Option<crate::providers::ApprovalBroker>,
474 questions: Option<crate::providers::QuestionBroker>,
478 tasks: crate::providers::TaskBroker,
483 config_watch: Option<tokio::task::AbortHandle>,
487}
488
489impl EffectRunner {
490 #[must_use]
492 pub fn new(msg_tx: MsgSender, workdir: PathBuf) -> Self {
493 let persistence_state = Arc::new(Mutex::new(PersistenceState::new(workdir.clone())));
494 Self {
495 tasks: crate::providers::TaskBroker::new(msg_tx.clone()),
496 msg_tx,
497 scopes: HashMap::new(),
498 cancelled_turns: VecDeque::new(),
499 detached: tokio::task::JoinSet::new(),
500 persistence_state,
501 persistence_tail: None,
502 workdir,
503 providers: None,
504 tools: None,
505 task_id: None,
506 terminal_title_enabled: true,
507 owns_global_mcp: true,
508 approval: None,
509 questions: None,
510 config_watch: None,
511 }
512 }
513
514 #[must_use]
520 pub fn sender(&self) -> MsgSender {
521 self.msg_tx.clone()
522 }
523
524 #[must_use]
528 pub fn with_interactive_approvals(mut self) -> Self {
529 self.approval = Some(crate::providers::ApprovalBroker::new(self.msg_tx.clone()));
530 self
531 }
532
533 #[must_use]
537 pub fn with_interactive_questions(mut self) -> Self {
538 self.questions = Some(crate::providers::QuestionBroker::new(self.msg_tx.clone()));
539 self
540 }
541
542 pub fn spawn_config_watcher(&mut self, cwd: PathBuf, memory: MemoryConfig) {
548 let handle = self.detached.spawn(config_watch::config_watcher(
549 self.msg_tx.clone(),
550 cwd,
551 memory,
552 ));
553 self.config_watch = Some(handle);
554 }
555
556 #[must_use]
559 pub fn with_task_id(mut self, task_id: Option<String>) -> Self {
560 self.task_id = task_id;
561 self
562 }
563
564 #[must_use]
566 pub fn without_terminal_title(mut self) -> Self {
567 self.terminal_title_enabled = false;
568 self
569 }
570
571 #[must_use]
574 pub fn without_global_mcp_shutdown(mut self) -> Self {
575 self.owns_global_mcp = false;
576 self
577 }
578
579 pub fn with_bindings(
584 mut self,
585 providers: Arc<ProviderFactory>,
586 tools: Arc<ToolRegistry>,
587 ) -> Self {
588 self.providers = Some(providers);
589 self.tools = Some(tools);
590 self
591 }
592
593 #[must_use]
597 pub fn pair(workdir: PathBuf) -> (Self, mpsc::Receiver<Msg>) {
598 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
599 (Self::new(tx, workdir), rx)
600 }
601
602 #[must_use]
605 pub fn pair_with_bindings(
606 workdir: PathBuf,
607 config: Config,
608 tools: Arc<ToolRegistry>,
609 ) -> (Self, mpsc::Receiver<Msg>) {
610 let providers = Arc::new(ProviderFactory::new(config));
611 Self::pair_from(workdir, providers, tools)
612 }
613
614 pub fn pair_from(
619 workdir: PathBuf,
620 providers: Arc<ProviderFactory>,
621 tools: Arc<ToolRegistry>,
622 ) -> (Self, mpsc::Receiver<Msg>) {
623 let (tx, rx) = mpsc::channel(MSG_CHANNEL_CAPACITY);
624 (Self::new(tx, workdir).with_bindings(providers, tools), rx)
625 }
626
627 pub fn pair_from_with_task(
628 workdir: PathBuf,
629 providers: Arc<ProviderFactory>,
630 tools: Arc<ToolRegistry>,
631 task_id: Option<String>,
632 ) -> (Self, mpsc::Receiver<Msg>) {
633 let (runner, rx) = Self::pair_from(workdir, providers, tools);
634 (runner.with_task_id(task_id), rx)
635 }
636
637 pub fn new_child(
642 msg_tx: MsgSender,
643 workdir: PathBuf,
644 providers: Arc<ProviderFactory>,
645 tools: Arc<ToolRegistry>,
646 ) -> Self {
647 Self::new(msg_tx, workdir)
655 .with_bindings(providers, tools)
656 .without_terminal_title()
657 .without_global_mcp_shutdown()
658 }
659
660 fn scope_mut(&mut self, turn: TurnId) -> &mut TurnScope {
664 self.scopes
665 .entry(turn)
666 .or_insert_with(|| TurnScope::new(turn))
667 }
668
669 fn tombstone_turn(&mut self, turn: TurnId) {
673 if self.cancelled_turns.contains(&turn) {
674 return;
675 }
676 if self.cancelled_turns.len() >= CANCELLED_TOMBSTONE_CAP {
677 self.cancelled_turns.pop_front();
678 }
679 self.cancelled_turns.push_back(turn);
680 }
681
682 fn is_tombstoned(&self, turn: TurnId) -> bool {
685 self.cancelled_turns.contains(&turn)
686 }
687
688 fn dispatch_query(&mut self, query: Query) {
694 let tx = self.msg_tx.clone();
695 match query {
696 Query::LoadConversation { id } => self.query_load_conversation(id, tx),
697 Query::ListConversations => self.query_list_conversations(tx),
698 Query::ListAvailableModels => {
699 let providers = self.providers.clone();
700 self.detached.spawn(async move {
701 let choices = discover_available_models(providers).await;
702 let _ = tx
703 .send(Msg::QueryResult(QueryResult::AvailableModelsListed(
704 choices,
705 )))
706 .await;
707 });
708 },
709 Query::ListProjectFiles => {
710 let workdir = self.workdir.clone();
711 self.send_blocking_query(move || {
712 QueryResult::ProjectFilesListed(walk_project_files(&workdir))
713 });
714 },
715 Query::ListRuntimeTasks { limit } => self.send_blocking_query(move || {
716 QueryResult::RuntimeTasksListed(
717 crate::runtime_client::RuntimeClient::auto()
718 .list_tasks(limit)
719 .map(|read| read.value)
720 .unwrap_or_default(),
721 )
722 }),
723 Query::LoadRuntimeTask { id } => self.send_blocking_query(move || {
724 let (task, events) = crate::runtime_client::RuntimeClient::auto()
725 .task_detail(&id)
726 .map(|read| (Some(Box::new(read.value.task)), read.value.events))
727 .unwrap_or((None, Vec::new()));
728 QueryResult::RuntimeTaskLoaded { task, events }
729 }),
730 Query::ListRuntimeProcesses { limit } => self.send_blocking_query(move || {
731 QueryResult::RuntimeProcessesListed(
732 crate::runtime_client::RuntimeClient::auto()
733 .list_processes(limit)
734 .map(|read| read.value)
735 .unwrap_or_default(),
736 )
737 }),
738 Query::ListRuntimeApprovals => self.send_blocking_query(move || {
739 QueryResult::RuntimeApprovalsListed(
740 crate::runtime_client::RuntimeClient::auto()
741 .list_approvals()
742 .map(|read| read.value)
743 .unwrap_or_default(),
744 )
745 }),
746 Query::ListRuntimeCheckpoints { limit } => self.send_blocking_query(move || {
747 QueryResult::RuntimeCheckpointsListed(
748 crate::runtime_client::RuntimeClient::auto()
749 .list_checkpoints(limit)
750 .map(|read| read.value)
751 .unwrap_or_default(),
752 )
753 }),
754 Query::ListForkCheckpoints {
755 session_id,
756 message_index,
757 } => self.send_blocking_query(move || {
758 QueryResult::ForkCheckpointsFound(
759 mermaid_runtime::with_shared_store(|store| {
760 store
761 .checkpoints()
762 .list_for_session(&session_id, message_index as i64)
763 })
764 .unwrap_or_default(),
765 )
766 }),
767 Query::ListRuntimePlugins => self.send_blocking_query(move || {
768 QueryResult::RuntimePluginsListed(
769 crate::runtime_client::RuntimeClient::auto()
770 .list_plugins()
771 .map(|read| read.value)
772 .unwrap_or_default(),
773 )
774 }),
775 }
776 }
777
778 fn query_load_conversation(&mut self, id: String, tx: MsgSender) {
782 let workdir = self.workdir.clone();
783 self.detached.spawn(async move {
784 match crate::session::ConversationManager::new(&workdir) {
785 Ok(mgr) => match mgr.load_conversation(&id) {
786 Ok(history) => {
787 let _ = tx
788 .send(Msg::QueryResult(QueryResult::ConversationLoaded(Box::new(
789 history,
790 ))))
791 .await;
792 },
793 Err(e) => {
794 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
795 },
796 },
797 Err(e) => {
798 tracing::warn!(error = %e, "ConversationManager init failed");
799 },
800 }
801 });
802 }
803
804 fn query_list_conversations(&mut self, tx: MsgSender) {
807 let workdir = self.workdir.clone();
808 self.detached.spawn(async move {
809 let summaries = match crate::session::ConversationManager::new(&workdir) {
810 Ok(mgr) => mgr
811 .list_conversation_metas()
812 .unwrap_or_default()
813 .into_iter()
814 .map(|m| mermaid_domain::ConversationSummary {
815 id: m.id,
816 title: m.title,
817 message_count: m.message_count,
818 updated_at: m.updated_at.to_rfc3339(),
819 })
820 .collect(),
821 Err(_) => Vec::new(),
822 };
823 let _ = tx
824 .send(Msg::QueryResult(QueryResult::ConversationsListed(
825 summaries,
826 )))
827 .await;
828 });
829 }
830
831 fn send_blocking_query(&mut self, run: impl FnOnce() -> QueryResult + Send + 'static) {
836 let tx = self.msg_tx.clone();
837 self.detached.spawn_blocking(move || {
838 let _ = tx.blocking_send(Msg::QueryResult(run()));
839 });
840 }
841
842 fn drop_scope(&mut self, turn: TurnId) {
852 self.tombstone_turn(turn);
857 if let Some(mut scope) = self.scopes.remove(&turn) {
858 scope.cancel();
859 let tx = self.msg_tx.clone();
860 self.detached.spawn(async move {
861 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
862 .await
863 .is_err()
864 {
865 tracing::warn!(
866 turn = %turn,
867 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
868 "cancel drain timed out; aborting remaining scoped tasks"
869 );
870 }
871 let _ = tx.send(Msg::TurnCancelled(turn)).await;
872 });
873 } else {
874 let tx = self.msg_tx.clone();
881 self.detached.spawn(async move {
882 let _ = tx.send(Msg::TurnCancelled(turn)).await;
883 });
884 }
885 }
886
887 #[must_use]
890 pub fn scope_count(&self) -> usize {
891 self.scopes.len()
892 }
893
894 fn reap_empty_scopes(&mut self) {
903 self.reap_detached();
904 self.scopes.retain(|_, scope| {
905 scope.drain_completed();
906 !scope.is_empty()
907 });
908 }
909
910 fn reap_detached(&mut self) {
915 while let Some(result) = self.detached.try_join_next() {
916 if let Err(e) = result
917 && !e.is_cancelled()
918 {
919 tracing::warn!(error = %e, "effect: detached task panicked");
920 }
921 }
922 }
923
924 #[expect(
928 clippy::too_many_lines,
929 reason = "the effect router: one arm per Cmd variant, each spawning or calling the \
930 handler that owns that effect; the arms are short and the routing table is the point, so \
931 the length is the Cmd count and drops only as commands are retired"
932 )]
933 pub fn dispatch(&mut self, cmd: Cmd) {
934 self.reap_empty_scopes();
937 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
938
939 if let Some(turn) = cmd.scope_turn()
947 && self.is_tombstoned(turn)
948 {
949 tracing::debug!(
950 cmd = %cmd.summary(),
951 turn = %turn,
952 "effect: dropping turn-scoped cmd for an already-cancelled turn"
953 );
954 return;
955 }
956
957 match cmd {
958 Cmd::CallModel { turn, mut request } => {
959 let tx = self.msg_tx.clone();
960 let providers = self.providers.clone();
961 if let Some(tools) = &self.tools
970 && request.output_schema.is_none()
971 {
972 let mut enriched =
973 filter_suppressed(tools.describe_all(), &request.suppressed_builtin_tools);
974 let builtin_tokens = mermaid_domain::estimate_tool_schema_tokens(&enriched);
979 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
985 tracing::debug!(
986 error = %e,
987 "effect: dropped builtin tool-schema token estimate (channel full); \
988 /context preview may be briefly stale"
989 );
990 }
991 enriched.append(&mut request.tools);
992 request.tools = enriched;
993 }
994 self.detached.spawn(fire_plugin_hooks(
997 "prompt_submit",
998 serde_json::json!({
999 "turn_id": turn.0,
1000 "model_id": request.model_id.clone(),
1001 "message_count": request.messages.len(),
1002 "tool_count": request.tools.len(),
1003 }),
1004 ));
1005 let task_usage = self.tasks.clone();
1008 let scope = self.scope_mut(turn);
1009 let token = scope.token();
1010 scope.spawn(async move {
1011 use futures::FutureExt;
1012 let fallback_tx = tx.clone();
1013 if std::panic::AssertUnwindSafe(dispatch_call_model(
1014 tx, providers, turn, request, token, task_usage,
1015 ))
1016 .catch_unwind()
1017 .await
1018 .is_err()
1019 {
1020 tracing::error!(turn = %turn, "dispatch_call_model panicked");
1025 let _ = fallback_tx
1026 .send(Msg::UpstreamError {
1027 turn,
1028 error: mermaid_model::models::UserFacingError {
1029 summary: "Internal error".to_string(),
1030 message: "The model dispatch task panicked unexpectedly."
1031 .to_string(),
1032 suggestion: "This is a bug. Please retry; if it persists, \
1033 check the logs."
1034 .to_string(),
1035 category: mermaid_model::models::ErrorCategory::Internal,
1036 recoverable: true,
1037 },
1038 })
1039 .await;
1040 }
1041 });
1042 },
1043 Cmd::CompactConversation { turn, mut request } => {
1044 let tx = self.msg_tx.clone();
1045 let providers = self.providers.clone();
1046 if let Some(tools) = &self.tools {
1047 let mut enriched = tools.describe_all();
1048 enriched.append(&mut request.chat.tools);
1049 request.chat.tools = enriched;
1050 }
1051 let trigger = request.trigger;
1054 let scope = self.scope_mut(turn);
1055 let token = scope.token();
1056 scope.spawn(async move {
1057 use futures::FutureExt;
1058 let fallback_tx = tx.clone();
1059 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
1060 tx, providers, turn, request, token,
1061 ))
1062 .catch_unwind()
1063 .await
1064 .is_err()
1065 {
1066 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
1072 let _ = fallback_tx
1073 .send(Msg::CompactionFailed {
1074 turn,
1075 trigger,
1076 message: "the compaction task panicked unexpectedly".to_string(),
1077 kind: mermaid_domain::StatusKind::Error,
1078 })
1079 .await;
1080 }
1081 });
1082 },
1083 Cmd::ExecuteTool {
1084 turn,
1085 call_id,
1086 source,
1087 dispatch,
1088 } => {
1089 let tx = self.msg_tx.clone();
1090 let tools = self.tools.clone();
1091 let workdir = self.workdir.clone();
1092 let config = self
1097 .providers
1098 .as_ref()
1099 .map(|p| Arc::new(p.config().clone()))
1100 .unwrap_or_else(|| Arc::new(mermaid_domain::Config::default()));
1101 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
1109 if dispatch.safety_mode == mermaid_runtime::SafetyMode::Auto
1110 || dispatch.plan_file.is_some()
1111 {
1112 self.providers.as_ref().map(|p| {
1113 let model = config
1114 .safety
1115 .auto_classifier_model
1116 .clone()
1117 .unwrap_or_else(|| dispatch.model_id.clone());
1118 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
1119 as Arc<dyn crate::providers::AutoClassifier>
1120 })
1121 } else {
1122 None
1123 };
1124 let services = crate::providers::ctx::ToolServices {
1125 workdir,
1126 config,
1127 task_id: self.task_id.clone(),
1128 notify: Some(self.msg_tx.clone()),
1132 classifier,
1133 approval: self.approval.clone(),
1134 questions: self.questions.clone(),
1135 tasks: Some(self.tasks.clone()),
1136 };
1137 let scope = self.scope_mut(turn);
1138 let signals = crate::providers::ctx::TurnSignals {
1139 token: scope.token(),
1140 background: scope.background_token(),
1141 web_bytes: scope.web_bytes(),
1142 };
1143 scope.spawn(async move {
1144 use futures::FutureExt;
1145 let fallback_tx = tx.clone();
1146 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
1147 tx, tools, turn, call_id, source, signals, dispatch, services,
1148 ))
1149 .catch_unwind()
1150 .await
1151 .is_err()
1152 {
1153 tracing::error!(
1158 turn = %turn,
1159 call_id = call_id.0,
1160 "dispatch_execute_tool panicked"
1161 );
1162 let _ = fallback_tx
1163 .send(Msg::ToolFinished {
1164 turn,
1165 call_id,
1166 outcome: mermaid_domain::ToolOutcome::error(
1167 "internal error: the tool execution task panicked".to_string(),
1168 0.0,
1169 ),
1170 })
1171 .await;
1172 }
1173 });
1174 },
1175 Cmd::ResolveApproval { call_id, decision } => {
1176 if let Some(broker) = &self.approval {
1179 broker.resolve(call_id, decision.into());
1180 }
1181 },
1182 Cmd::ResolveQuestion {
1183 call_id,
1184 resolution,
1185 } => {
1186 if let Some(broker) = &self.questions {
1189 broker.resolve(call_id, resolution);
1190 }
1191 },
1192 Cmd::SyncTaskStore(store) => {
1193 self.tasks.seed(store);
1197 },
1198 Cmd::EnsureScratchpad { session_id } => {
1199 let tx = self.msg_tx.clone();
1200 let workdir = self.workdir.clone();
1201 self.detached.spawn(async move {
1202 match crate::session::scratchpad::ensure(&workdir, &session_id) {
1203 Ok(path) => {
1204 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
1205 },
1206 Err(err) => {
1207 tracing::warn!(error = %err, "failed to create session scratchpad");
1210 },
1211 }
1212 if let Err(err) = crate::session::scratchpad::sweep_stale(
1215 crate::session::scratchpad::RETENTION_DAYS,
1216 ) {
1217 tracing::warn!(error = %err, "scratchpad sweep failed");
1218 }
1219 });
1220 },
1221 Cmd::ListScratchpad { path } => {
1222 let tx = self.msg_tx.clone();
1225 self.detached.spawn(async move {
1226 let text = tokio::task::spawn_blocking(move || {
1227 crate::session::scratchpad::list_text(&path)
1228 })
1229 .await
1230 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
1231 let _ = tx.send(Msg::RuntimeText(text)).await;
1232 });
1233 },
1234 Cmd::UserTaskEdit(edit) => {
1235 let broker = self.tasks.clone();
1240 let tx = self.msg_tx.clone();
1241 self.detached.spawn(async move {
1242 let (line, _snapshot) = broker.user_edit(edit).await;
1243 let _ = tx
1248 .send(Msg::TaskNotice {
1249 text: format!(
1250 "The user edited the task checklist: {line}. Acknowledge and \
1251 incorporate this into your plan."
1252 ),
1253 })
1254 .await;
1255 let _ = tx.send(Msg::TransientStatus { text: line }).await;
1256 });
1257 },
1258 Cmd::NotifyTaskCompleted {
1259 task,
1260 completed,
1261 total,
1262 } => {
1263 let payload = serde_json::json!({
1271 "task_id": task.id,
1272 "subject": task.subject,
1273 "description": task.description,
1274 "evidence": task.evidence,
1275 "completed": completed,
1276 "total": total,
1277 });
1278 let broker = self.tasks.clone();
1279 let tx = self.msg_tx.clone();
1280 self.detached.spawn(async move {
1281 let gate = run_plugin_hooks_gated("task_completed", payload).await;
1282 let Some((plugin, reason)) = gate.deny else {
1283 return;
1284 };
1285 let reason = mermaid_model::utils::redact_secrets(&reason);
1286 let _ = broker
1287 .update(vec![mermaid_domain::ChecklistEdit {
1288 id: task.id,
1289 status: Some(mermaid_domain::ChecklistStatus::InProgress),
1290 ..mermaid_domain::ChecklistEdit::default()
1291 }])
1292 .await;
1293 let _ = tx
1294 .send(Msg::TaskNotice {
1295 text: format!(
1296 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1297 {reason}. The task is back in_progress; address the reason \
1298 before completing it again.",
1299 task.id, task.subject
1300 ),
1301 })
1302 .await;
1303 let _ = tx
1304 .send(Msg::TransientStatus {
1305 text: format!(
1306 "task #{} completion vetoed by {plugin}: {reason}",
1307 task.id
1308 ),
1309 })
1310 .await;
1311 });
1312 },
1313 Cmd::CancelScope(turn) => {
1314 self.drop_scope(turn);
1315 },
1316 Cmd::BackgroundScope(turn) => {
1317 self.scope_mut(turn).background();
1321 },
1322 Cmd::SaveConversation { snapshot, events } => {
1323 self.queue_persistence(PersistenceJob::Conversation {
1324 snapshot: Box::new(snapshot),
1325 events,
1326 });
1327 },
1328 Cmd::SaveCompaction {
1329 record,
1330 conversation,
1331 events,
1332 } => {
1333 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1334 PendingCompactionSave {
1335 record,
1336 conversation,
1337 events,
1338 events_appended: false,
1339 task_id: self.task_id.clone(),
1340 },
1341 )));
1342 },
1343 Cmd::SaveProcess(process) => {
1344 let task_id = self.task_id.clone();
1345 self.detached.spawn(async move {
1346 let status = process.status;
1347 let _ = mermaid_runtime::with_shared_store(|store| {
1348 store.processes().upsert(mermaid_runtime::NewProcess {
1349 id: Some(process.id),
1350 task_id,
1351 pid: process.pid,
1352 command: process.command,
1353 cwd: process.cwd,
1354 log_path: Some(process.log_path),
1355 detected_url: process.detected_url,
1356 status,
1357 health: None,
1358 })
1359 });
1360 });
1361 },
1362 Cmd::PersistPlanConfig(plan) => {
1363 self.detached.spawn(async move {
1364 if let Err(err) = crate::app::persist_plan_config(&plan) {
1365 tracing::warn!(error = %err, "failed to persist [plan] config");
1366 }
1367 });
1368 },
1369 Cmd::PersistLastModel(model) => {
1370 self.detached.spawn(async move {
1371 if let Err(err) = crate::app::persist_last_model(&model) {
1372 tracing::warn!(error = %err, "failed to persist last-used model");
1373 }
1374 });
1375 },
1376 Cmd::PersistReasoningFor { model_id, level } => {
1377 self.detached.spawn(async move {
1378 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1379 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1380 }
1381 });
1382 },
1383 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1384 self.detached.spawn(async move {
1385 if let Err(err) =
1386 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1387 {
1388 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1389 }
1390 });
1391 },
1392 Cmd::PersistOllamaOffload(enabled) => {
1393 self.detached.spawn(async move {
1394 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1395 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1396 }
1397 });
1398 },
1399 Cmd::PersistUiTheme(theme) => {
1400 self.detached.spawn(async move {
1401 if let Err(err) = crate::app::persist_ui_theme(theme) {
1402 tracing::warn!(error = %err, "failed to persist theme");
1403 }
1404 });
1405 },
1406 Cmd::ComposeInEditor { .. } => {
1407 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1411 },
1412 Cmd::ListMemory => {
1413 let tx = self.msg_tx.clone();
1414 let workdir = self.workdir.clone();
1415 self.detached.spawn(async move {
1416 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1417 let text = match crate::app::memory::load(&workdir, &cfg) {
1418 Some(mem) => mem.index,
1419 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(),
1420 };
1421 let _ = tx.send(Msg::RuntimeText(text)).await;
1422 });
1423 },
1424 Cmd::RememberMemory { text } => {
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 name = memory_title_from_text(&text);
1430 let status = match crate::app::memory::write_memory(
1431 &workdir,
1432 mermaid_domain::MemoryScope::ProjectPrivate,
1433 &name,
1434 &text,
1435 &[],
1436 &text,
1437 ) {
1438 Ok(_) => format!("Remembered: {name}"),
1439 Err(e) => format!("Couldn't save memory: {e}"),
1440 };
1441 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1442 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1443 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1444 });
1445 },
1446 Cmd::ForgetMemory { id } => {
1447 let tx = self.msg_tx.clone();
1448 let workdir = self.workdir.clone();
1449 self.detached.spawn(async move {
1450 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1451 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1452 Ok(Some(_)) => format!("Forgot: {id}"),
1453 Ok(None) => format!("No memory named '{id}'"),
1454 Err(e) => format!("Couldn't forget memory: {e}"),
1455 };
1456 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1457 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1458 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1459 });
1460 },
1461 Cmd::ConsolidateMemory { model_id } => {
1462 let tx = self.msg_tx.clone();
1463 let workdir = self.workdir.clone();
1464 let providers = self.providers.clone();
1465 self.detached.spawn(async move {
1466 consolidate_memory(tx, providers, workdir, model_id).await;
1467 });
1468 },
1469 Cmd::Query(query) => self.dispatch_query(query),
1470 Cmd::ShowRuntimeProcessLogs { id } => {
1471 let tx = self.msg_tx.clone();
1472 self.detached.spawn_blocking(move || {
1473 let text = crate::runtime_client::RuntimeClient::auto()
1474 .process_log(&id, None)
1475 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1476 .unwrap_or_else(|err| format!("Process log error: {err}"));
1477 let _ = tx.blocking_send(Msg::RuntimeText(text));
1478 });
1479 },
1480 Cmd::StopRuntimeProcess { id } => {
1481 let tx = self.msg_tx.clone();
1482 self.detached.spawn_blocking(move || {
1483 let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1484 Ok(response) => Msg::TransientStatus {
1485 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1486 },
1487 Err(err) => Msg::TransientStatus {
1488 text: format!("Process stop failed: {err}"),
1489 },
1490 };
1491 let _ = tx.blocking_send(msg);
1492 });
1493 },
1494 Cmd::KillBackgroundAgent { agent_id } => {
1495 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1499 if let Some(spawner) = spawner {
1500 match agent_id {
1501 Some(id) => {
1502 if let crate::providers::tool::subagent::KillResult::Evicted(
1507 workspace,
1508 ) = spawner.kill_detached(&id)
1509 {
1510 self.detached.spawn(async move {
1514 workspace.discard().await;
1515 });
1516 }
1517 },
1518 None => {
1519 spawner.kill_all_detached();
1520 },
1521 }
1522 }
1523 },
1524 Cmd::RestartRuntimeProcess { id } => {
1525 let tx = self.msg_tx.clone();
1526 self.detached.spawn_blocking(move || {
1527 let msg = match crate::runtime_client::RuntimeClient::auto()
1528 .restart_process(&id)
1529 {
1530 Ok(response) => Msg::TransientStatus {
1531 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1532 },
1533 Err(err) => Msg::TransientStatus {
1534 text: format!("Process restart failed: {err}"),
1535 },
1536 };
1537 let _ = tx.blocking_send(msg);
1538 });
1539 },
1540 Cmd::OpenRuntimeTarget { target } => {
1541 self.detached.spawn_blocking(move || {
1542 let resolved = crate::runtime_client::RuntimeService::open_default()
1543 .and_then(|service| service.resolve_open_target(&target))
1544 .unwrap_or(target);
1545 if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1549 tracing::warn!(error = %err, "refusing to open runtime target");
1550 return;
1551 }
1552 mermaid_model::utils::open_file(resolved);
1553 });
1554 },
1555 Cmd::ShowRuntimePorts => {
1556 let tx = self.msg_tx.clone();
1557 self.detached.spawn_blocking(move || {
1558 let text = crate::runtime_client::RuntimeClient::auto()
1559 .ports()
1560 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1561 .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1562 let _ = tx.blocking_send(Msg::RuntimeText(text));
1563 });
1564 },
1565 Cmd::DecideRuntimeApproval { id, decision } => {
1566 let tx = self.msg_tx.clone();
1567 self.detached.spawn_blocking(move || {
1568 let result = if decision == "approved" {
1569 crate::runtime_client::RuntimeClient::auto().approve(&id)
1570 } else {
1571 crate::runtime_client::RuntimeClient::auto().deny(&id)
1572 };
1573 let msg = match result {
1574 Ok(result) => Msg::TransientStatus {
1575 text: if result.replayed {
1576 format!("Approval {} {}: {}", id, decision, result.summary)
1577 } else {
1578 format!("Approval {id} {decision}")
1579 },
1580 },
1581 Err(err) => Msg::TransientStatus {
1582 text: format!("Approval update failed: {err}"),
1583 },
1584 };
1585 let _ = tx.blocking_send(msg);
1586 });
1587 },
1588 Cmd::UpdateRuntimeTaskStatus {
1589 id,
1590 status,
1591 final_report,
1592 } => {
1593 let tx = self.msg_tx.clone();
1594 self.detached.spawn_blocking(move || {
1595 let msg = match mermaid_runtime::with_shared_store(|store| {
1596 store
1597 .tasks()
1598 .update_status(&id, status, final_report.as_deref())
1599 }) {
1600 Ok(()) => Msg::TransientStatus {
1601 text: format!("Task {id} -> {status}"),
1602 },
1603 Err(err) => Msg::TransientStatus {
1604 text: format!("Task update failed: {err}"),
1605 },
1606 };
1607 let _ = tx.blocking_send(msg);
1608 });
1609 },
1610 Cmd::CreateRuntimeCheckpoint { paths } => {
1611 let tx = self.msg_tx.clone();
1612 let workdir = self.workdir.clone();
1613 self.detached.spawn_blocking(move || {
1614 let pending_action = Some(serde_json::json!({
1615 "source": "tui",
1616 "command": "checkpoint",
1617 }));
1618 let msg = match mermaid_runtime::create_checkpoint(
1619 &workdir,
1620 &paths,
1621 pending_action,
1622 ) {
1623 Ok(manifest) => Msg::TransientStatus {
1624 text: format!(
1625 "Checkpoint {} created for {} path(s)",
1626 manifest.id,
1627 manifest.files.len()
1628 ),
1629 },
1630 Err(err) => Msg::TransientStatus {
1631 text: format!("Checkpoint failed: {err}"),
1632 },
1633 };
1634 let _ = tx.blocking_send(msg);
1635 });
1636 },
1637 Cmd::RestoreRuntimeCheckpoint { id } => {
1638 let tx = self.msg_tx.clone();
1639 self.detached.spawn_blocking(move || {
1640 let msg = match crate::runtime_client::RuntimeClient::auto()
1641 .restore_checkpoint(&id)
1642 {
1643 Ok(result) => Msg::TransientStatus {
1644 text: format!(
1645 "Restored checkpoint {} ({} file(s)){}",
1646 result.checkpoint.id,
1647 result.checkpoint.files.len(),
1648 if result.checkpoint.pending_action.is_some() {
1649 "; pending action available in checkpoint manifest"
1650 } else {
1651 ""
1652 }
1653 ),
1654 },
1655 Err(err) => Msg::TransientStatus {
1656 text: format!("Restore failed: {err}"),
1657 },
1658 };
1659 let _ = tx.blocking_send(msg);
1660 });
1661 },
1662 Cmd::ShowRuntimeModelInfo { model } => {
1663 let tx = self.msg_tx.clone();
1664 self.detached.spawn_blocking(move || {
1665 let text = runtime_model_info_text(&model);
1666 let _ = tx.blocking_send(Msg::RuntimeText(text));
1667 });
1668 },
1669 Cmd::InitMcpServers(configs) => {
1670 let tx = self.msg_tx.clone();
1671 self.detached
1672 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1673 },
1674 Cmd::StopMcpServer { name } => {
1675 let tx = self.msg_tx.clone();
1676 self.detached.spawn(async move {
1677 if let Some(mgr) = crate::mcp::manager_ref::get() {
1680 mgr.stop_server(&name).await;
1681 }
1682 let _ = tx.send(Msg::McpServerStopped { name }).await;
1683 });
1684 },
1685 Cmd::PullOllamaModel { model } => {
1686 let tx = self.msg_tx.clone();
1687 self.detached.spawn(async move {
1688 dispatch_pull_ollama_model(tx, model).await;
1689 });
1690 },
1691 Cmd::OpenInSystem(path) => {
1692 self.detached.spawn(async move {
1693 let _ = tokio::task::spawn_blocking(move || {
1694 mermaid_model::utils::open_file(&path);
1695 })
1696 .await;
1697 });
1698 },
1699 Cmd::WriteImageToTemp {
1700 path,
1701 bytes,
1702 format: _,
1703 } => {
1704 self.detached.spawn(async move {
1705 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1706 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1707 }
1708 });
1709 },
1710 Cmd::ReadClipboard => {
1711 let tx = self.msg_tx.clone();
1712 self.detached.spawn(async move {
1713 dispatch_read_clipboard(tx).await;
1714 });
1715 },
1716 Cmd::ProbeVision { model_id, warn } => {
1717 let tx = self.msg_tx.clone();
1718 let providers = self.providers.clone();
1719 self.detached.spawn(async move {
1720 dispatch_probe_vision(model_id, warn, providers, tx).await;
1721 });
1722 },
1723 Cmd::CopyToClipboard(text) => {
1724 let tx = self.msg_tx.clone();
1725 self.detached.spawn(async move {
1726 dispatch_copy_to_clipboard(text, tx).await;
1727 });
1728 },
1729 Cmd::Exit => {
1730 },
1735 Cmd::SetTerminalTitle(title) => {
1736 if !self.terminal_title_enabled {
1737 return;
1738 }
1739 self.detached.spawn_blocking(move || {
1745 use std::io::Write;
1746 let seq = format!("\x1b]2;{title}\x07");
1747 let mut stdout = std::io::stdout();
1748 let _ = stdout.write_all(seq.as_bytes());
1749 let _ = stdout.flush();
1750 });
1751 },
1752 Cmd::AlertUser => {
1753 if !self.terminal_title_enabled {
1754 return;
1755 }
1756 self.detached.spawn_blocking(|| {
1759 use std::io::Write;
1760 let mut stdout = std::io::stdout();
1761 let _ = stdout.write_all(b"\x07");
1762 let _ = stdout.flush();
1763 });
1764 },
1765 }
1766 }
1767
1768 fn queue_persistence(&mut self, job: PersistenceJob) {
1769 let previous = self.persistence_tail.take();
1770 let state = Arc::clone(&self.persistence_state);
1771 let tx = self.msg_tx.clone();
1772 self.persistence_tail = Some(tokio::spawn(async move {
1773 if let Some(previous) = previous
1774 && let Err(error) = previous.await
1775 {
1776 tracing::warn!(error = %error, "previous persistence job panicked");
1777 }
1778
1779 let result = tokio::task::spawn_blocking(move || {
1780 state
1781 .lock()
1782 .unwrap_or_else(|error| error.into_inner())
1783 .process(job)
1784 })
1785 .await;
1786
1787 match result {
1788 Ok((events, outcome)) => {
1789 if outcome.is_ok() || !events.is_empty() {
1793 let _ = tx.send(Msg::SessionSaved).await;
1794 }
1795 for event in events {
1796 fire_compaction_hook(&event).await;
1797 }
1798 if let Err(error) = outcome {
1799 tracing::warn!(
1800 error = %error,
1801 "persistence job failed; compaction barriers remain queued"
1802 );
1803 }
1804 },
1805 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1806 }
1807 }));
1808 }
1809
1810 pub async fn shutdown(mut self) {
1814 for (id, scope) in self.scopes.iter() {
1815 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1816 scope.cancel();
1817 }
1818
1819 if let Some(handle) = self.config_watch.take() {
1822 handle.abort();
1823 }
1824
1825 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1827
1828 let owns_global_mcp = self.owns_global_mcp;
1829 let persistence_tail = self.persistence_tail.take();
1830 let persistence_state = Arc::clone(&self.persistence_state);
1831 let drain = async {
1832 if let Some(tail) = persistence_tail
1833 && let Err(error) = tail.await
1834 {
1835 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1836 }
1837 match tokio::task::spawn_blocking(move || {
1838 let mut state = persistence_state
1839 .lock()
1840 .unwrap_or_else(|error| error.into_inner());
1841 let drained = state.retry_all_blocked();
1842 if let Err(error) = state.flush_checkpoints() {
1846 tracing::warn!(
1847 error = %error,
1848 "shutdown: could not flush a session checkpoint; the log still has everything, so the next resume just folds further"
1849 );
1850 }
1851 drop(state);
1852 drained
1853 })
1854 .await
1855 {
1856 Ok((events, outcome)) => {
1857 for event in events {
1860 fire_compaction_hook(&event).await;
1861 }
1862 if let Err(error) = outcome {
1863 tracing::warn!(
1864 error = %error,
1865 "shutdown: compaction persistence barrier retry failed"
1866 );
1867 }
1868 },
1869 Err(error) => tracing::warn!(
1870 error = %error,
1871 "shutdown: compaction persistence barrier panicked"
1872 ),
1873 }
1874 if owns_global_mcp {
1878 let _ = tokio::time::timeout(
1884 std::time::Duration::from_secs(2),
1885 crate::mcp::manager_ref::wait_ready(),
1886 )
1887 .await;
1888 if let Some(mgr) = crate::mcp::manager_ref::get() {
1895 mgr.shutdown().await;
1896 }
1897 crate::searxng::shutdown().await;
1901 }
1902 for (id, mut scope) in self.scopes.drain() {
1908 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1909 .await
1910 .is_err()
1911 {
1912 tracing::warn!(
1913 turn = %id,
1914 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1915 "shutdown: scope drain timed out; aborting its remaining tasks"
1916 );
1917 }
1918 }
1919 while let Some(result) = self.detached.join_next().await {
1920 if let Err(e) = result
1921 && !e.is_cancelled()
1922 {
1923 tracing::warn!(error = %e, "shutdown: detached task panic");
1924 }
1925 }
1926 };
1927
1928 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1929 }
1930}
1931
1932fn note_stream_usage(
1940 tasks: &crate::providers::TaskBroker,
1941 usage: &Option<mermaid_model::models::TokenUsage>,
1942) {
1943 if let Some(usage) = usage {
1944 tasks.add_tokens(usage.completion_tokens as u64);
1945 }
1946}
1947
1948fn filter_suppressed(
1952 tools: Vec<mermaid_domain::ToolDefinition>,
1953 suppressed: &[&'static str],
1954) -> Vec<mermaid_domain::ToolDefinition> {
1955 if suppressed.is_empty() {
1956 return tools;
1957 }
1958 tools
1959 .into_iter()
1960 .filter(|t| !suppressed.contains(&t.name.as_str()))
1961 .collect()
1962}
1963
1964mod compaction;
1965mod memory;
1966mod model_call;
1967mod tool_call;
1968
1969use compaction::*;
1970use memory::*;
1971use model_call::*;
1972use tool_call::*;
1973
1974#[cfg(test)]
1975mod tests {
1976 #[test]
1980 fn compaction_row_maps_every_field_it_claims_to() {
1981 use mermaid_domain::{CompactionEvent, CompactionReviewStatus, CompactionTrigger};
1982 let record = CompactionEvent {
1983 id: "cmp-1".to_string(),
1984 trigger: CompactionTrigger::Manual,
1985 created_at: chrono::Local::now(),
1986 before_tokens: 9_000,
1987 after_tokens: 1_200,
1988 archived_message_count: 40,
1989 preserved_message_count: 6,
1990 preserved_turn_count: 3,
1991 summary_tokens: 450,
1992 duration_secs: 1.5,
1993 review_status: CompactionReviewStatus::Reviewed,
1994 review_error: None,
1995 focus: None,
1996 archive_path: None,
1997 };
1998 let row = compaction_row(
1999 &record,
2000 std::path::Path::new("/tmp/archive.json"),
2001 Some("task-7".to_string()),
2002 "sess-3".to_string(),
2003 );
2004 assert_eq!(row.id.as_deref(), Some("cmp-1"));
2005 assert_eq!(row.task_id.as_deref(), Some("task-7"));
2006 assert_eq!(row.session_id.as_deref(), Some("sess-3"));
2007 assert_eq!(row.source_token_estimate, Some(9_000));
2008 assert_eq!(row.summary_token_count, Some(450));
2009 assert_eq!(row.preserved_turns, Some(3));
2010 assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
2011 assert_eq!(
2012 row.verification_status.as_deref(),
2013 Some(CompactionReviewStatus::Reviewed.as_str())
2014 );
2015 }
2016
2017 use super::*;
2018 use mermaid_domain::ToolCallId;
2019 use std::time::Duration;
2020
2021 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2022 EffectRunner::pair(PathBuf::from("/tmp"))
2023 }
2024
2025 #[test]
2028 fn filter_suppressed_drops_only_the_named_tools() {
2029 let def = |name: &str| mermaid_domain::ToolDefinition {
2030 name: name.to_string(),
2031 description: String::new(),
2032 input_schema: serde_json::json!({}),
2033 };
2034 let tools = vec![def("task_create"), def("task_list"), def("task_update")];
2035 let kept = filter_suppressed(tools.clone(), &["task_create", "task_update"]);
2036 assert_eq!(
2037 kept.iter().map(|t| t.name.as_str()).collect::<Vec<_>>(),
2038 vec!["task_list"]
2039 );
2040 let kept = filter_suppressed(tools, &[]);
2041 assert_eq!(kept.len(), 3, "empty suppression list is a no-op");
2042 }
2043
2044 #[test]
2045 fn runtime_tool_payloads_are_redacted_before_serialization() {
2046 let payload = serde_json::json!({
2047 "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
2048 "authorization": "opaque-secret-value",
2049 "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
2050 });
2051 let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
2052 assert!(
2053 !serialized.contains("hunter2"),
2054 "URL password leaked: {serialized}"
2055 );
2056 assert!(
2057 !serialized.contains("opaque-signature"),
2058 "signed URL leaked: {serialized}"
2059 );
2060 assert!(
2061 !serialized.contains("private"),
2062 "URL fragment leaked: {serialized}"
2063 );
2064 assert!(
2065 !serialized.contains("opaque-secret-value"),
2066 "credential-named field leaked: {serialized}"
2067 );
2068 assert!(
2069 !serialized.contains("abcdef123456ghijkl"),
2070 "bearer token leaked: {serialized}"
2071 );
2072 assert!(
2073 !serialized.contains("sk-abcdefghijklmnop1234"),
2074 "secret-shaped fetched content leaked: {serialized}"
2075 );
2076 assert!(serialized.contains("[REDACTED]"));
2077 }
2078
2079 #[test]
2080 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
2081 let root = std::env::temp_dir().join(format!(
2082 "mermaid-walk-{}-{:?}",
2083 std::process::id(),
2084 std::thread::current().id()
2085 ));
2086 let _ = std::fs::remove_dir_all(&root);
2087 std::fs::create_dir_all(root.join("src")).unwrap();
2088 std::fs::create_dir_all(root.join("target")).unwrap();
2089 std::fs::create_dir_all(root.join(".git")).unwrap();
2090 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
2091 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2092 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
2093 std::fs::write(root.join("README.md"), "readme").unwrap();
2094 std::fs::write(root.join(".hidden"), "hidden").unwrap();
2095
2096 let files = walk_project_files(&root);
2097 assert_eq!(
2098 files,
2099 vec![
2100 "README.md".to_string(),
2101 "src/".to_string(),
2102 "src/main.rs".to_string(),
2103 ],
2104 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
2105 );
2106 let _ = std::fs::remove_dir_all(&root);
2107 }
2108
2109 #[test]
2110 fn new_child_suppresses_terminal_title() {
2111 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2115 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2116 let tools = Arc::new(ToolRegistry::new());
2117 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2118 assert!(
2119 !child.terminal_title_enabled,
2120 "subagent child runner must suppress terminal-title escapes"
2121 );
2122 }
2123
2124 #[test]
2125 fn new_child_does_not_own_global_mcp_shutdown() {
2126 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2131 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2132 let tools = Arc::new(ToolRegistry::new());
2133 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2134 assert!(
2135 !child.owns_global_mcp,
2136 "child runner must not reap the shared global MCP manager"
2137 );
2138 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2139 assert!(
2140 top.owns_global_mcp,
2141 "top-level runner still owns the global MCP reap"
2142 );
2143 }
2144
2145 #[test]
2146 fn parse_prune_plan_extracts_json_amid_prose() {
2147 let plan = parse_prune_plan(
2148 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2149 )
2150 .expect("should parse");
2151 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2152 assert_eq!(plan.reason, "dupes");
2153 }
2154
2155 #[test]
2156 fn parse_prune_plan_handles_empty_and_garbage() {
2157 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2158 .expect("empty plan parses");
2159 assert!(empty.prune.is_empty());
2160 assert!(parse_prune_plan("no json here").is_none());
2161 }
2162
2163 #[test]
2164 fn memory_title_from_text_is_short_and_nonempty() {
2165 assert_eq!(
2166 memory_title_from_text("prefer ripgrep over grep"),
2167 "prefer ripgrep over grep"
2168 );
2169 assert_eq!(memory_title_from_text(" "), "memory");
2170 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2171 assert!(long.split_whitespace().count() <= 8);
2172 }
2173
2174 #[tokio::test]
2175 async fn dispatch_exit_is_noop_on_runner_state() {
2176 let (mut r, _rx) = runner();
2177 r.dispatch(Cmd::Exit);
2178 assert_eq!(r.scope_count(), 0);
2179 }
2180
2181 #[tokio::test]
2182 async fn dispatch_save_emits_session_saved() {
2183 let (mut r, mut rx) = runner();
2184 r.dispatch(Cmd::SaveConversation {
2185 snapshot: mermaid_domain::ConversationHistory::new(
2186 "/p".to_string(),
2187 "m".to_string(),
2188 chrono::Local::now(),
2189 ),
2190 events: Vec::new(),
2191 });
2192 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2193 .await
2194 .expect("sender emits")
2195 .expect("channel alive");
2196 assert!(matches!(msg, Msg::SessionSaved));
2197 }
2198
2199 #[cfg(unix)]
2200 #[tokio::test]
2201 async fn init_mcp_servers_emits_incremental_errored_msgs() {
2202 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2206 let mut configs = std::collections::HashMap::new();
2207 for name in ["one", "two"] {
2208 configs.insert(
2209 name.to_string(),
2210 mermaid_domain::McpServerConfig {
2211 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2212 ..Default::default()
2213 },
2214 );
2215 }
2216 dispatch_init_mcp_servers(configs, tx).await;
2217 let mut errored = Vec::new();
2218 while let Ok(msg) = rx.try_recv() {
2219 match msg {
2220 Msg::McpServerErrored { name, .. } => errored.push(name),
2221 other => panic!("unexpected msg: {other:?}"),
2222 }
2223 }
2224 errored.sort();
2225 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2226 assert!(crate::mcp::manager_ref::is_ready());
2227 }
2228
2229 #[tokio::test]
2230 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2231 let (mut r, mut rx) = runner();
2232 let turn = TurnId(77);
2233 {
2234 let scope = r.scope_mut(turn);
2235 scope.spawn(async {
2236 std::future::pending::<()>().await;
2237 });
2238 }
2239 assert_eq!(r.scope_count(), 1);
2240
2241 let start = std::time::Instant::now();
2242 r.dispatch(Cmd::CancelScope(turn));
2243 assert_eq!(r.scope_count(), 0);
2244 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2245 .await
2246 .expect("bounded cancel should emit terminal message")
2247 .expect("channel alive");
2248 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2249 assert!(
2250 start.elapsed() < Duration::from_millis(500),
2251 "cancel terminal message took {:?}",
2252 start.elapsed()
2253 );
2254 }
2255
2256 #[tokio::test]
2257 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2258 let (mut r, mut rx) = runner();
2264 let turn = TurnId(88);
2265 {
2266 let scope = r.scope_mut(turn);
2267 scope.spawn(async {}); }
2269 assert_eq!(r.scope_count(), 1);
2270
2271 tokio::time::sleep(Duration::from_millis(20)).await;
2273 r.dispatch(Cmd::Exit);
2274 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2275
2276 r.dispatch(Cmd::CancelScope(turn));
2279 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2280 .await
2281 .expect("cancel on a reaped scope must still emit a terminal message")
2282 .expect("channel alive");
2283 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2284 }
2285
2286 #[tokio::test]
2287 async fn dispatch_call_model_creates_scope() {
2288 let (mut r, _rx) = runner();
2289 let turn = TurnId(7);
2290 let request = mermaid_domain::ChatRequest {
2291 model_id: "test/m".to_string(),
2292 messages: vec![],
2293 system_prompt: String::new(),
2294 instructions: None,
2295 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2296 temperature: 0.7,
2297 max_tokens: 4096,
2298 tools: vec![],
2299
2300 ollama_num_ctx: None,
2301 ollama_allow_ram_offload: None,
2302 resolved_context_window: None,
2303 resolved_max_output: None,
2304 output_schema: None,
2305 suppress_auto_compact: false,
2306 suppressed_builtin_tools: Vec::new(),
2307 };
2308 r.dispatch(Cmd::CallModel { turn, request });
2309 assert_eq!(r.scope_count(), 1);
2310 }
2311
2312 #[tokio::test]
2316 async fn empty_scopes_are_reaped_on_next_dispatch() {
2317 let (mut r, mut rx) = runner();
2318 let turn = TurnId(42);
2319 let request = mermaid_domain::ChatRequest {
2320 model_id: "test/m".to_string(),
2321 messages: vec![],
2322 system_prompt: String::new(),
2323 instructions: None,
2324 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2325 temperature: 0.7,
2326 max_tokens: 4096,
2327 tools: vec![],
2328
2329 ollama_num_ctx: None,
2330 ollama_allow_ram_offload: None,
2331 resolved_context_window: None,
2332 resolved_max_output: None,
2333 output_schema: None,
2334 suppress_auto_compact: false,
2335 suppressed_builtin_tools: Vec::new(),
2336 };
2337 r.dispatch(Cmd::CallModel { turn, request });
2338 assert_eq!(r.scope_count(), 1);
2339
2340 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2345 .await
2346 .expect("upstream error arrived")
2347 .expect("channel alive");
2348 assert!(matches!(msg, Msg::UpstreamError { .. }));
2349
2350 tokio::task::yield_now().await;
2352
2353 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2355 assert_eq!(
2356 r.scope_count(),
2357 0,
2358 "completed scope must be reaped on next dispatch"
2359 );
2360 }
2361
2362 #[tokio::test]
2363 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2364 let (mut r, mut rx) = runner();
2365 let turn = TurnId(7);
2366 let call_id = ToolCallId(1);
2367 let source = mermaid_model::models::tool_call::ToolCall {
2368 id: Some("c1".to_string()),
2369 function: mermaid_model::models::tool_call::FunctionCall {
2370 name: "read_file".to_string(),
2371 arguments: serde_json::json!({"path": "x"}),
2372 },
2373 };
2374 r.dispatch(Cmd::ExecuteTool {
2375 turn,
2376 call_id,
2377 source,
2378 dispatch: mermaid_domain::ToolDispatch {
2379 model_id: "ollama/test".to_string(),
2380 safety_mode: mermaid_runtime::SafetyMode::Ask,
2381 plan_file: None,
2382 plan_permissions: mermaid_domain::PlanPermissions::default(),
2383 context_percent: None,
2384 intent: None,
2385 session_id: "sess-test".to_string(),
2386 message_index: 0,
2387 scratchpad: None,
2388 },
2389 });
2390 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2391 .await
2392 .expect("some msg")
2393 .expect("channel alive");
2394 assert!(matches!(
2395 first,
2396 Msg::ToolStarted {
2397 turn: t,
2398 call_id: c,
2399 } if t == turn && c == call_id
2400 ));
2401 }
2402
2403 #[tokio::test]
2404 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2405 let (mut r, _rx) = runner();
2406 let turn = TurnId(9);
2407 r.dispatch(Cmd::CallModel {
2408 turn,
2409 request: mermaid_domain::ChatRequest {
2410 model_id: "m".to_string(),
2411 messages: vec![],
2412 system_prompt: String::new(),
2413 instructions: None,
2414 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2415 temperature: 0.7,
2416 max_tokens: 4096,
2417 tools: vec![],
2418
2419 ollama_num_ctx: None,
2420 ollama_allow_ram_offload: None,
2421 resolved_context_window: None,
2422 resolved_max_output: None,
2423 output_schema: None,
2424 suppress_auto_compact: false,
2425 suppressed_builtin_tools: Vec::new(),
2426 },
2427 });
2428 assert_eq!(r.scope_count(), 1);
2429
2430 r.dispatch(Cmd::CancelScope(turn));
2431 assert_eq!(r.scope_count(), 0);
2432 }
2433
2434 #[tokio::test]
2435 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2436 let (mut r, _rx) = runner();
2442 let req = || mermaid_domain::ChatRequest {
2443 model_id: "test/m".to_string(),
2444 messages: vec![],
2445 system_prompt: String::new(),
2446 instructions: None,
2447 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2448 temperature: 0.7,
2449 max_tokens: 4096,
2450 tools: vec![],
2451 ollama_num_ctx: None,
2452 ollama_allow_ram_offload: None,
2453 resolved_context_window: None,
2454 resolved_max_output: None,
2455 output_schema: None,
2456 suppress_auto_compact: false,
2457 suppressed_builtin_tools: Vec::new(),
2458 };
2459 let turn = TurnId(123);
2460
2461 r.dispatch(Cmd::CallModel {
2462 turn,
2463 request: req(),
2464 });
2465 assert_eq!(r.scope_count(), 1);
2466
2467 r.dispatch(Cmd::CancelScope(turn));
2469 assert_eq!(r.scope_count(), 0);
2470
2471 r.dispatch(Cmd::CallModel {
2473 turn,
2474 request: req(),
2475 });
2476 assert_eq!(
2477 r.scope_count(),
2478 0,
2479 "a cancelled turn must not be resurrected by a late scoped Cmd"
2480 );
2481
2482 r.dispatch(Cmd::CallModel {
2484 turn: TurnId(124),
2485 request: req(),
2486 });
2487 assert_eq!(
2488 r.scope_count(),
2489 1,
2490 "a fresh turn must still create its scope normally"
2491 );
2492 }
2493
2494 #[tokio::test]
2495 async fn shutdown_drains_pending_saves() {
2496 let (mut r, _rx) = runner();
2497 for _ in 0..5 {
2498 r.dispatch(Cmd::SaveConversation {
2499 snapshot: mermaid_domain::ConversationHistory::new(
2500 "/p".to_string(),
2501 "m".to_string(),
2502 chrono::Local::now(),
2503 ),
2504 events: Vec::new(),
2505 });
2506 }
2507 let start = std::time::Instant::now();
2509 r.shutdown().await;
2510 assert!(start.elapsed() < Duration::from_secs(2));
2511 }
2512
2513 fn persistence_fixture(
2514 root: &std::path::Path,
2515 record_id: &str,
2516 ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2517 let now = chrono::Local::now();
2518 let mut full = mermaid_domain::ConversationHistory::new(
2519 root.display().to_string(),
2520 "test/model".to_string(),
2521 now,
2522 );
2523 full.add_messages(
2524 &[mermaid_model::models::ChatMessage::user("raw history")],
2525 now,
2526 );
2527 let mut compacted = full.clone();
2528 compacted.replace_messages(
2529 vec![mermaid_model::models::ChatMessage::user(
2530 "compacted checkpoint",
2531 )],
2532 now,
2533 );
2534 let record = mermaid_domain::CompactionEvent {
2535 id: record_id.to_string(),
2536 trigger: mermaid_domain::CompactionTrigger::Manual,
2537 created_at: now,
2538 before_tokens: 100,
2539 after_tokens: 20,
2540 archived_message_count: 1,
2541 preserved_message_count: 1,
2542 preserved_turn_count: 1,
2543 summary_tokens: 10,
2544 duration_secs: 0.1,
2545 review_status: mermaid_domain::CompactionReviewStatus::Reviewed,
2546 review_error: None,
2547 focus: None,
2548 archive_path: None,
2549 };
2550 (
2551 full,
2552 PendingCompactionSave {
2553 record,
2554 conversation: compacted,
2555 events: vec![mermaid_domain::SessionEvent::Input {
2558 text: "compaction boundary".to_string(),
2559 }],
2560 events_appended: false,
2561 task_id: None,
2562 },
2563 )
2564 }
2565
2566 fn block_event_log(root: &std::path::Path, id: &str) {
2571 let dir = root.join(".mermaid").join("conversations");
2572 std::fs::create_dir_all(&dir).expect("conversations dir");
2573 std::fs::create_dir_all(dir.join(format!("{id}.jsonl"))).expect("plant a blocker");
2574 }
2575
2576 fn one_message_save(
2578 conversation: &mut mermaid_domain::ConversationHistory,
2579 text: &str,
2580 ) -> PersistenceJob {
2581 let message = mermaid_model::models::ChatMessage::user(text);
2582 conversation.add_messages(std::slice::from_ref(&message), chrono::Local::now());
2583 PersistenceJob::Conversation {
2584 snapshot: Box::new(conversation.clone()),
2585 events: vec![mermaid_domain::SessionEvent::Message { message }],
2586 }
2587 }
2588
2589 #[test]
2590 fn the_checkpoint_stops_being_written_on_every_save() {
2591 let root = std::env::temp_dir().join(format!(
2595 "mermaid-throttle-{}-{:?}",
2596 std::process::id(),
2597 std::thread::current().id()
2598 ));
2599 let _ = std::fs::remove_dir_all(&root);
2600 let manager = crate::session::ConversationManager::new(&root).unwrap();
2601 let mut conversation = mermaid_domain::ConversationHistory::new(
2602 root.display().to_string(),
2603 "test/model".to_string(),
2604 chrono::Local::now(),
2605 );
2606 let mut state = PersistenceState::new(root.clone());
2607
2608 state
2611 .process(one_message_save(&mut conversation, "first"))
2612 .1
2613 .unwrap();
2614 let checkpoint = manager
2615 .conversations_dir()
2616 .join(format!("{}.json", conversation.id));
2617 assert!(
2618 !checkpoint.exists(),
2619 "a single save must not rewrite the transcript"
2620 );
2621
2622 let resumed = manager.load_conversation(&conversation.id).unwrap();
2624 assert_eq!(resumed.messages().len(), 1);
2625 assert_eq!(resumed.messages()[0].content, "first");
2626
2627 for i in 0..CHECKPOINT_EVERY_EVENTS {
2629 state
2630 .process(one_message_save(&mut conversation, &format!("m{i}")))
2631 .1
2632 .unwrap();
2633 }
2634 assert!(
2635 checkpoint.exists(),
2636 "crossing {CHECKPOINT_EVERY_EVENTS} events must materialize a checkpoint"
2637 );
2638 let resumed = manager.load_conversation(&conversation.id).unwrap();
2639 assert_eq!(resumed.messages().len(), CHECKPOINT_EVERY_EVENTS + 1);
2640 let _ = std::fs::remove_dir_all(root);
2641 }
2642
2643 #[test]
2644 fn shutdown_flushes_the_checkpoint_it_was_holding() {
2645 let root = std::env::temp_dir().join(format!(
2646 "mermaid-flush-{}-{:?}",
2647 std::process::id(),
2648 std::thread::current().id()
2649 ));
2650 let _ = std::fs::remove_dir_all(&root);
2651 let manager = crate::session::ConversationManager::new(&root).unwrap();
2652 let mut conversation = mermaid_domain::ConversationHistory::new(
2653 root.display().to_string(),
2654 "test/model".to_string(),
2655 chrono::Local::now(),
2656 );
2657 let mut state = PersistenceState::new(root.clone());
2658 state
2659 .process(one_message_save(&mut conversation, "only message"))
2660 .1
2661 .unwrap();
2662
2663 let checkpoint = manager
2664 .conversations_dir()
2665 .join(format!("{}.json", conversation.id));
2666 assert!(!checkpoint.exists());
2667 state.flush_checkpoints().unwrap();
2668 assert!(
2669 checkpoint.exists(),
2670 "a clean exit must leave a current checkpoint"
2671 );
2672 let raw = std::fs::read_to_string(&checkpoint).unwrap();
2674 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2675 assert!(
2676 value.get("checkpoint_seq").is_some(),
2677 "a flushed checkpoint must be placeable in its log: {raw}"
2678 );
2679 let _ = std::fs::remove_dir_all(root);
2680 }
2681
2682 #[test]
2683 fn a_failed_append_keeps_its_events_for_the_next_save() {
2684 let root = std::env::temp_dir().join(format!(
2687 "mermaid-unappended-{}-{:?}",
2688 std::process::id(),
2689 std::thread::current().id()
2690 ));
2691 let _ = std::fs::remove_dir_all(&root);
2692 let manager = crate::session::ConversationManager::new(&root).unwrap();
2693 let mut conversation = mermaid_domain::ConversationHistory::new(
2694 root.display().to_string(),
2695 "test/model".to_string(),
2696 chrono::Local::now(),
2697 );
2698 let mut state = PersistenceState::new(root.clone());
2699
2700 std::fs::create_dir_all(
2702 manager
2703 .conversations_dir()
2704 .join(format!("{}.jsonl", conversation.id)),
2705 )
2706 .expect("plant a blocker");
2707 let job = one_message_save(&mut conversation, "must survive");
2708 assert!(state.process(job).1.is_err(), "the append must fail");
2709 assert_eq!(
2710 state.unappended.get(&conversation.id).map(Vec::len),
2711 Some(1),
2712 "the batch must be held, not dropped"
2713 );
2714
2715 std::fs::remove_dir_all(
2717 manager
2718 .conversations_dir()
2719 .join(format!("{}.jsonl", conversation.id)),
2720 )
2721 .expect("unblock");
2722 state
2723 .process(one_message_save(&mut conversation, "and this one"))
2724 .1
2725 .unwrap();
2726 assert!(state.unappended.is_empty(), "the hold must clear");
2727 state.flush_checkpoints().unwrap();
2728
2729 let resumed = manager.load_conversation(&conversation.id).unwrap();
2730 let texts: Vec<&str> = resumed
2731 .messages()
2732 .iter()
2733 .map(|m| m.content.as_str())
2734 .collect();
2735 assert!(
2736 texts.contains(&"must survive"),
2737 "the event held over a failed append must reach the log: {texts:?}"
2738 );
2739 assert!(texts.contains(&"and this one"), "{texts:?}");
2740 let _ = std::fs::remove_dir_all(root);
2741 }
2742
2743 #[test]
2744 fn persistence_orders_compaction_before_newer_conversation_save() {
2745 let root = std::env::temp_dir().join(format!(
2746 "mermaid-persistence-order-{}-{:?}",
2747 std::process::id(),
2748 std::thread::current().id()
2749 ));
2750 let _ = std::fs::remove_dir_all(&root);
2751 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2752 let manager = crate::session::ConversationManager::new(&root).unwrap();
2753 manager.save_conversation(&full).unwrap();
2754
2755 let mut state = PersistenceState::new(root.clone());
2756 let (events, outcome) =
2757 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2758 outcome.unwrap();
2759 assert_eq!(events.len(), 1);
2760 let mut newer = compaction.conversation;
2761 let reply = mermaid_model::models::ChatMessage::assistant("new assistant reply");
2762 newer.add_messages(std::slice::from_ref(&reply), chrono::Local::now());
2763 let (_, outcome) = state.process(PersistenceJob::Conversation {
2768 snapshot: Box::new(newer),
2769 events: vec![mermaid_domain::SessionEvent::Message { message: reply }],
2770 });
2771 outcome.unwrap();
2772
2773 let loaded = crate::session::ConversationManager::new(&root)
2774 .unwrap()
2775 .load_conversation(&full.id)
2776 .unwrap();
2777 assert!(
2778 loaded
2779 .messages()
2780 .iter()
2781 .any(|message| message.content == "new assistant reply")
2782 );
2783 let _ = std::fs::remove_dir_all(root);
2784 }
2785
2786 #[test]
2787 fn failed_event_append_blocks_later_stripped_conversation_save() {
2788 let root = std::env::temp_dir().join(format!(
2789 "mermaid-persistence-barrier-{}-{:?}",
2790 std::process::id(),
2791 std::thread::current().id()
2792 ));
2793 let _ = std::fs::remove_dir_all(&root);
2794 let (full, compaction) = persistence_fixture(&root, "compact_blocked");
2795 let manager = crate::session::ConversationManager::new(&root).unwrap();
2796 manager.save_conversation(&full).unwrap();
2797 block_event_log(&root, &full.id);
2798
2799 let mut state = PersistenceState::new(root.clone());
2800 assert!(
2801 state
2802 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2803 .1
2804 .is_err()
2805 );
2806 assert!(
2807 state
2808 .process(PersistenceJob::Conversation {
2809 snapshot: Box::new(compaction.conversation),
2810 events: Vec::new(),
2811 })
2812 .1
2813 .is_err()
2814 );
2815 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2816
2817 let loaded = crate::session::ConversationManager::new(&root)
2818 .unwrap()
2819 .load_conversation(&full.id)
2820 .unwrap();
2821 assert_eq!(loaded.messages()[0].content, "raw history");
2822 let _ = std::fs::remove_dir_all(root);
2823 }
2824
2825 #[test]
2826 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2827 let root = std::env::temp_dir().join(format!(
2828 "mermaid-persistence-queue-{}-{:?}",
2829 std::process::id(),
2830 std::thread::current().id()
2831 ));
2832 let _ = std::fs::remove_dir_all(&root);
2833 let (full, first) = persistence_fixture(&root, "compact_first");
2834 let mut second = first.clone();
2835 second.record.id = "compact_second".to_string();
2836 block_event_log(&root, &full.id);
2837
2838 let mut state = PersistenceState::new(root.clone());
2839 assert!(
2840 state
2841 .process(PersistenceJob::Compaction(Box::new(first)))
2842 .1
2843 .is_err()
2844 );
2845 assert!(
2848 state
2849 .process(PersistenceJob::Compaction(Box::new(second)))
2850 .1
2851 .is_err()
2852 );
2853 let queued = state.blocked.get(&full.id).expect("barrier queue");
2854 assert_eq!(queued.len(), 2);
2855 assert_eq!(queued[0].record.id, "compact_first");
2856 assert_eq!(queued[1].record.id, "compact_second");
2857 let _ = std::fs::remove_dir_all(root);
2858 }
2859
2860 #[test]
2861 fn retry_all_blocked_attempts_every_conversation() {
2862 let root = std::env::temp_dir().join(format!(
2863 "mermaid-persistence-drain-{}-{:?}",
2864 std::process::id(),
2865 std::thread::current().id()
2866 ));
2867 let _ = std::fs::remove_dir_all(&root);
2868 let (bad_full, bad) = persistence_fixture(&root, "compact_bad");
2869 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2870 block_event_log(&root, &bad_full.id);
2871 good_full.id = "20990101_000000_001".to_string();
2875 good.conversation.id = good_full.id.clone();
2876
2877 let mut state = PersistenceState::new(root.clone());
2878 state
2879 .blocked
2880 .entry(bad_full.id.clone())
2881 .or_default()
2882 .push_back(bad);
2883 state
2884 .blocked
2885 .entry(good_full.id.clone())
2886 .or_default()
2887 .push_back(good);
2888
2889 let (events, outcome) = state.retry_all_blocked();
2893 assert!(outcome.is_err());
2894 assert_eq!(events.len(), 1);
2895 assert_eq!(events[0].id, "compact_good");
2896 assert!(!state.blocked.contains_key(&good_full.id));
2897 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2898 let loaded = crate::session::ConversationManager::new(&root)
2899 .unwrap()
2900 .load_conversation(&good_full.id)
2901 .unwrap();
2902 assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2903 let _ = std::fs::remove_dir_all(root);
2904 }
2905
2906 #[test]
2907 fn partially_drained_barrier_reports_its_persisted_events() {
2908 let root = std::env::temp_dir().join(format!(
2909 "mermaid-persistence-partial-{}-{:?}",
2910 std::process::id(),
2911 std::thread::current().id()
2912 ));
2913 let _ = std::fs::remove_dir_all(&root);
2914 let (full, good) = persistence_fixture(&root, "compact_good");
2915 let mut bad = good.clone();
2916 bad.record.id = "compact_bad".to_string();
2917 bad.conversation.id = "../invalid".to_string();
2920
2921 let mut state = PersistenceState::new(root.clone());
2922 let queue = state.blocked.entry(full.id.clone()).or_default();
2923 queue.push_back(good);
2924 queue.push_back(bad);
2925
2926 let (events, outcome) = state.retry_blocked(&full.id);
2930 assert!(outcome.is_err());
2931 assert_eq!(events.len(), 1);
2932 assert_eq!(events[0].id, "compact_good");
2933 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2934 let _ = std::fs::remove_dir_all(root);
2935 }
2936}