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 }
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::ListOutputStyles => self.dispatch_list_output_styles(),
716 Query::LoadOutputStyle { name, project } => {
717 self.dispatch_load_output_style(name, project);
718 },
719 Query::ListRuntimeTasks { limit } => self.send_blocking_query(move || {
720 QueryResult::RuntimeTasksListed(
721 crate::runtime_client::RuntimeClient::auto()
722 .list_tasks(limit)
723 .map(|read| read.value)
724 .unwrap_or_default(),
725 )
726 }),
727 Query::LoadRuntimeTask { id } => self.send_blocking_query(move || {
728 let (task, events) = crate::runtime_client::RuntimeClient::auto()
729 .task_detail(&id)
730 .map(|read| (Some(Box::new(read.value.task)), read.value.events))
731 .unwrap_or((None, Vec::new()));
732 QueryResult::RuntimeTaskLoaded { task, events }
733 }),
734 Query::ListRuntimeProcesses { limit } => self.send_blocking_query(move || {
735 QueryResult::RuntimeProcessesListed(
736 crate::runtime_client::RuntimeClient::auto()
737 .list_processes(limit)
738 .map(|read| read.value)
739 .unwrap_or_default(),
740 )
741 }),
742 Query::ListRuntimeApprovals => self.send_blocking_query(move || {
743 QueryResult::RuntimeApprovalsListed(
744 crate::runtime_client::RuntimeClient::auto()
745 .list_approvals()
746 .map(|read| read.value)
747 .unwrap_or_default(),
748 )
749 }),
750 Query::ListRuntimeCheckpoints { limit } => self.send_blocking_query(move || {
751 QueryResult::RuntimeCheckpointsListed(
752 crate::runtime_client::RuntimeClient::auto()
753 .list_checkpoints(limit)
754 .map(|read| read.value)
755 .unwrap_or_default(),
756 )
757 }),
758 Query::ListForkCheckpoints {
759 session_id,
760 message_index,
761 } => self.send_blocking_query(move || {
762 QueryResult::ForkCheckpointsFound(
763 mermaid_runtime::with_shared_store(|store| {
764 store
765 .checkpoints()
766 .list_for_session(&session_id, message_index as i64)
767 })
768 .unwrap_or_default(),
769 )
770 }),
771 Query::ListRuntimePlugins => self.send_blocking_query(move || {
772 QueryResult::RuntimePluginsListed(
773 crate::runtime_client::RuntimeClient::auto()
774 .list_plugins()
775 .map(|read| read.value)
776 .unwrap_or_default(),
777 )
778 }),
779 }
780 }
781
782 fn dispatch_list_output_styles(&mut self) {
785 let workdir = self.workdir.clone();
786 self.send_blocking_query(move || {
787 QueryResult::OutputStylesListed(crate::app::output_styles::list_styles(&workdir))
788 });
789 }
790
791 fn dispatch_load_output_style(&mut self, name: String, project: bool) {
795 let workdir = self.workdir.clone();
796 self.send_blocking_query(move || {
797 let loaded = crate::app::output_styles::load_style_body(&workdir, &name);
798 let (found, body, keep, custom, source) = match loaded {
799 Some((body, keep, custom, source)) => {
800 (true, body, keep, custom, source.to_string())
801 },
802 None => (false, String::new(), true, false, String::new()),
803 };
804 QueryResult::OutputStyleLoaded {
805 name,
806 project,
807 found,
808 body,
809 keep_coding_instructions: keep,
810 custom,
811 source,
812 }
813 });
814 }
815
816 fn query_load_conversation(&mut self, id: String, tx: MsgSender) {
820 let workdir = self.workdir.clone();
821 self.detached.spawn(async move {
822 match crate::session::ConversationManager::new(&workdir) {
823 Ok(mgr) => match mgr.load_conversation(&id) {
824 Ok(history) => {
825 let _ = tx
826 .send(Msg::QueryResult(QueryResult::ConversationLoaded(Box::new(
827 history,
828 ))))
829 .await;
830 },
831 Err(e) => {
832 tracing::warn!(id = %id, error = %e, "LoadConversation failed");
833 },
834 },
835 Err(e) => {
836 tracing::warn!(error = %e, "ConversationManager init failed");
837 },
838 }
839 });
840 }
841
842 fn query_list_conversations(&mut self, tx: MsgSender) {
845 let workdir = self.workdir.clone();
846 self.detached.spawn(async move {
847 let summaries = match crate::session::ConversationManager::new(&workdir) {
848 Ok(mgr) => mgr
849 .list_conversation_metas()
850 .unwrap_or_default()
851 .into_iter()
852 .map(|m| mermaid_domain::ConversationSummary {
853 id: m.id,
854 title: m.title,
855 message_count: m.message_count,
856 updated_at: m.updated_at.to_rfc3339(),
857 })
858 .collect(),
859 Err(_) => Vec::new(),
860 };
861 let _ = tx
862 .send(Msg::QueryResult(QueryResult::ConversationsListed(
863 summaries,
864 )))
865 .await;
866 });
867 }
868
869 fn send_blocking_query(&mut self, run: impl FnOnce() -> QueryResult + Send + 'static) {
874 let tx = self.msg_tx.clone();
875 self.detached.spawn_blocking(move || {
876 let _ = tx.blocking_send(Msg::QueryResult(run()));
877 });
878 }
879
880 fn drop_scope(&mut self, turn: TurnId) {
890 self.tombstone_turn(turn);
895 if let Some(mut scope) = self.scopes.remove(&turn) {
896 scope.cancel();
897 let tx = self.msg_tx.clone();
898 self.detached.spawn(async move {
899 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
900 .await
901 .is_err()
902 {
903 tracing::warn!(
904 turn = %turn,
905 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
906 "cancel drain timed out; aborting remaining scoped tasks"
907 );
908 }
909 let _ = tx.send(Msg::TurnCancelled(turn)).await;
910 });
911 } else {
912 let tx = self.msg_tx.clone();
919 self.detached.spawn(async move {
920 let _ = tx.send(Msg::TurnCancelled(turn)).await;
921 });
922 }
923 }
924
925 #[must_use]
928 pub fn scope_count(&self) -> usize {
929 self.scopes.len()
930 }
931
932 fn reap_empty_scopes(&mut self) {
941 self.reap_detached();
942 self.scopes.retain(|_, scope| {
943 scope.drain_completed();
944 !scope.is_empty()
945 });
946 }
947
948 fn reap_detached(&mut self) {
953 while let Some(result) = self.detached.try_join_next() {
954 if let Err(e) = result
955 && !e.is_cancelled()
956 {
957 tracing::warn!(error = %e, "effect: detached task panicked");
958 }
959 }
960 }
961
962 #[expect(
966 clippy::too_many_lines,
967 reason = "the effect router: one arm per Cmd variant, each spawning or calling the \
968 handler that owns that effect; the arms are short and the routing table is the point, so \
969 the length is the Cmd count and drops only as commands are retired"
970 )]
971 pub fn dispatch(&mut self, cmd: Cmd) {
972 self.reap_empty_scopes();
975 tracing::trace!(cmd = %cmd.summary(), "effect: dispatch");
976
977 if let Some(turn) = cmd.scope_turn()
985 && self.is_tombstoned(turn)
986 {
987 tracing::debug!(
988 cmd = %cmd.summary(),
989 turn = %turn,
990 "effect: dropping turn-scoped cmd for an already-cancelled turn"
991 );
992 return;
993 }
994
995 match cmd {
996 Cmd::CallModel { turn, mut request } => {
997 let tx = self.msg_tx.clone();
998 let providers = self.providers.clone();
999 if let Some(tools) = &self.tools
1008 && request.output_schema.is_none()
1009 {
1010 let mut enriched = tools.describe_all();
1011 let builtin_tokens = mermaid_domain::estimate_tool_schema_tokens(&enriched);
1015 if let Err(e) = tx.try_send(Msg::BuiltinToolSchemaTokens(builtin_tokens)) {
1021 tracing::debug!(
1022 error = %e,
1023 "effect: dropped builtin tool-schema token estimate (channel full); \
1024 /context preview may be briefly stale"
1025 );
1026 }
1027 enriched.append(&mut request.tools);
1028 request.tools = enriched;
1029 }
1030 self.detached.spawn(fire_plugin_hooks(
1033 "prompt_submit",
1034 serde_json::json!({
1035 "turn_id": turn.0,
1036 "model_id": request.model_id.clone(),
1037 "message_count": request.messages.len(),
1038 "tool_count": request.tools.len(),
1039 }),
1040 ));
1041 let task_usage = self.tasks.clone();
1044 let scope = self.scope_mut(turn);
1045 let token = scope.token();
1046 scope.spawn(async move {
1047 use futures::FutureExt;
1048 let fallback_tx = tx.clone();
1049 if std::panic::AssertUnwindSafe(dispatch_call_model(
1050 tx, providers, turn, request, token, task_usage,
1051 ))
1052 .catch_unwind()
1053 .await
1054 .is_err()
1055 {
1056 tracing::error!(turn = %turn, "dispatch_call_model panicked");
1061 let _ = fallback_tx
1062 .send(Msg::UpstreamError {
1063 turn,
1064 error: mermaid_model::models::UserFacingError {
1065 summary: "Internal error".to_string(),
1066 message: "The model dispatch task panicked unexpectedly."
1067 .to_string(),
1068 suggestion: "This is a bug. Please retry; if it persists, \
1069 check the logs."
1070 .to_string(),
1071 category: mermaid_model::models::ErrorCategory::Internal,
1072 recoverable: true,
1073 },
1074 })
1075 .await;
1076 }
1077 });
1078 },
1079 Cmd::CompactConversation { turn, mut request } => {
1080 let tx = self.msg_tx.clone();
1081 let providers = self.providers.clone();
1082 if let Some(tools) = &self.tools {
1083 let mut enriched = tools.describe_all();
1084 enriched.append(&mut request.chat.tools);
1085 request.chat.tools = enriched;
1086 }
1087 let trigger = request.trigger;
1090 let scope = self.scope_mut(turn);
1091 let token = scope.token();
1092 scope.spawn(async move {
1093 use futures::FutureExt;
1094 let fallback_tx = tx.clone();
1095 if std::panic::AssertUnwindSafe(dispatch_compact_conversation(
1096 tx, providers, turn, request, token,
1097 ))
1098 .catch_unwind()
1099 .await
1100 .is_err()
1101 {
1102 tracing::error!(turn = %turn, "dispatch_compact_conversation panicked");
1108 let _ = fallback_tx
1109 .send(Msg::CompactionFailed {
1110 turn,
1111 trigger,
1112 message: "the compaction task panicked unexpectedly".to_string(),
1113 kind: mermaid_domain::StatusKind::Error,
1114 })
1115 .await;
1116 }
1117 });
1118 },
1119 Cmd::ExecuteTool {
1120 turn,
1121 call_id,
1122 source,
1123 dispatch,
1124 } => {
1125 let tx = self.msg_tx.clone();
1126 let tools = self.tools.clone();
1127 let workdir = self.workdir.clone();
1128 let config = self
1133 .providers
1134 .as_ref()
1135 .map(|p| Arc::new(p.config().clone()))
1136 .unwrap_or_else(|| Arc::new(mermaid_domain::Config::default()));
1137 let classifier: Option<Arc<dyn crate::providers::AutoClassifier>> =
1142 if dispatch.safety_mode == mermaid_runtime::SafetyMode::Auto {
1143 self.providers.as_ref().map(|p| {
1144 let model = config
1145 .safety
1146 .auto_classifier_model
1147 .clone()
1148 .unwrap_or_else(|| dispatch.model_id.clone());
1149 Arc::new(crate::providers::ModelAutoClassifier::new(p.clone(), model))
1150 as Arc<dyn crate::providers::AutoClassifier>
1151 })
1152 } else {
1153 None
1154 };
1155 let services = crate::providers::ctx::ToolServices {
1156 workdir,
1157 config,
1158 task_id: self.task_id.clone(),
1159 notify: Some(self.msg_tx.clone()),
1163 classifier,
1164 approval: self.approval.clone(),
1165 questions: self.questions.clone(),
1166 tasks: Some(self.tasks.clone()),
1167 };
1168 let scope = self.scope_mut(turn);
1169 let signals = crate::providers::ctx::TurnSignals {
1170 token: scope.token(),
1171 background: scope.background_token(),
1172 web_bytes: scope.web_bytes(),
1173 };
1174 scope.spawn(async move {
1175 use futures::FutureExt;
1176 let fallback_tx = tx.clone();
1177 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
1178 tx, tools, turn, call_id, source, signals, dispatch, services,
1179 ))
1180 .catch_unwind()
1181 .await
1182 .is_err()
1183 {
1184 tracing::error!(
1189 turn = %turn,
1190 call_id = call_id.0,
1191 "dispatch_execute_tool panicked"
1192 );
1193 let _ = fallback_tx
1194 .send(Msg::ToolFinished {
1195 turn,
1196 call_id,
1197 outcome: mermaid_domain::ToolOutcome::error(
1198 "internal error: the tool execution task panicked".to_string(),
1199 None,
1200 ),
1201 })
1202 .await;
1203 }
1204 });
1205 },
1206 Cmd::ResolveApproval { call_id, decision } => {
1207 if let Some(broker) = &self.approval {
1210 broker.resolve(call_id, decision.into());
1211 }
1212 },
1213 Cmd::ResolveQuestion {
1214 call_id,
1215 resolution,
1216 } => {
1217 if let Some(broker) = &self.questions {
1220 broker.resolve(call_id, resolution);
1221 }
1222 },
1223 Cmd::SyncTaskStore(store) => {
1224 self.tasks.seed(store);
1228 },
1229 Cmd::EnsureScratchpad { session_id } => {
1230 let tx = self.msg_tx.clone();
1231 let workdir = self.workdir.clone();
1232 self.detached.spawn(async move {
1233 match crate::session::scratchpad::ensure(&workdir, &session_id) {
1234 Ok(path) => {
1235 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
1236 },
1237 Err(err) => {
1238 tracing::warn!(error = %err, "failed to create session scratchpad");
1241 },
1242 }
1243 if let Err(err) = crate::session::scratchpad::sweep_stale(
1246 crate::session::scratchpad::RETENTION_DAYS,
1247 ) {
1248 tracing::warn!(error = %err, "scratchpad sweep failed");
1249 }
1250 });
1251 },
1252 Cmd::ListScratchpad { path } => {
1253 let tx = self.msg_tx.clone();
1256 self.detached.spawn(async move {
1257 let text = tokio::task::spawn_blocking(move || {
1258 crate::session::scratchpad::list_text(&path)
1259 })
1260 .await
1261 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
1262 let _ = tx.send(Msg::RuntimeText(text)).await;
1263 });
1264 },
1265 Cmd::UserTaskEdit(edit) => {
1266 let broker = self.tasks.clone();
1271 let tx = self.msg_tx.clone();
1272 self.detached.spawn(async move {
1273 let (line, _snapshot) = broker.user_edit(edit).await;
1274 let _ = tx
1279 .send(Msg::TaskNotice {
1280 text: format!(
1281 "The user edited the task checklist: {line}. Acknowledge and \
1282 incorporate this into your plan."
1283 ),
1284 })
1285 .await;
1286 let _ = tx.send(Msg::TransientStatus { text: line }).await;
1287 });
1288 },
1289 Cmd::NotifyTaskCompleted {
1290 task,
1291 completed,
1292 total,
1293 } => {
1294 let payload = serde_json::json!({
1302 "task_id": task.id,
1303 "subject": task.subject,
1304 "description": task.description,
1305 "evidence": task.evidence,
1306 "completed": completed,
1307 "total": total,
1308 });
1309 let broker = self.tasks.clone();
1310 let tx = self.msg_tx.clone();
1311 self.detached.spawn(async move {
1312 let gate = run_plugin_hooks_gated("task_completed", payload).await;
1313 let Some((plugin, reason)) = gate.deny else {
1314 return;
1315 };
1316 let reason = mermaid_model::utils::redact_secrets(&reason);
1317 let _ = broker
1318 .update(vec![mermaid_domain::ChecklistEdit {
1319 id: task.id,
1320 status: Some(mermaid_domain::ChecklistStatus::InProgress),
1321 ..mermaid_domain::ChecklistEdit::default()
1322 }])
1323 .await;
1324 let _ = tx
1325 .send(Msg::TaskNotice {
1326 text: format!(
1327 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1328 {reason}. The task is back in_progress; address the reason \
1329 before completing it again.",
1330 task.id, task.subject
1331 ),
1332 })
1333 .await;
1334 let _ = tx
1335 .send(Msg::TransientStatus {
1336 text: format!(
1337 "task #{} completion vetoed by {plugin}: {reason}",
1338 task.id
1339 ),
1340 })
1341 .await;
1342 });
1343 },
1344 Cmd::CancelScope(turn) => {
1345 self.drop_scope(turn);
1346 },
1347 Cmd::BackgroundScope(turn) => {
1348 self.scope_mut(turn).background();
1352 },
1353 Cmd::SaveConversation { snapshot, events } => {
1354 self.queue_persistence(PersistenceJob::Conversation {
1355 snapshot: Box::new(snapshot),
1356 events,
1357 });
1358 },
1359 Cmd::SaveCompaction {
1360 record,
1361 conversation,
1362 events,
1363 } => {
1364 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1365 PendingCompactionSave {
1366 record,
1367 conversation,
1368 events,
1369 events_appended: false,
1370 task_id: self.task_id.clone(),
1371 },
1372 )));
1373 },
1374 Cmd::SaveProcess(process) => {
1375 let task_id = self.task_id.clone();
1376 self.detached.spawn(async move {
1377 let status = process.status;
1378 let _ = mermaid_runtime::with_shared_store(|store| {
1379 store.processes().upsert(mermaid_runtime::NewProcess {
1380 id: Some(process.id),
1381 task_id,
1382 pid: process.pid,
1383 command: process.command,
1384 cwd: process.cwd,
1385 log_path: Some(process.log_path),
1386 detected_url: process.detected_url,
1387 status,
1388 health: None,
1389 })
1390 });
1391 });
1392 },
1393 Cmd::PersistLastModel(model) => {
1394 self.detached.spawn(async move {
1395 if let Err(err) = crate::app::persist_last_model(&model) {
1396 tracing::warn!(error = %err, "failed to persist last-used model");
1397 }
1398 });
1399 },
1400 Cmd::PersistReasoningFor { model_id, level } => {
1401 self.detached.spawn(async move {
1402 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1403 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1404 }
1405 });
1406 },
1407 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1408 self.detached.spawn(async move {
1409 if let Err(err) =
1410 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1411 {
1412 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1413 }
1414 });
1415 },
1416 Cmd::PersistOllamaOffload(enabled) => {
1417 self.detached.spawn(async move {
1418 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1419 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1420 }
1421 });
1422 },
1423 Cmd::PersistUiTheme(theme) => {
1424 self.detached.spawn(async move {
1425 if let Err(err) = crate::app::persist_ui_theme(theme) {
1426 tracing::warn!(error = %err, "failed to persist theme");
1427 }
1428 });
1429 },
1430 Cmd::PersistOutputStyle { style } => {
1431 self.detached.spawn(async move {
1432 if let Err(err) = crate::app::persist_output_style(&style) {
1433 tracing::warn!(error = %err, "failed to persist output style");
1434 }
1435 });
1436 },
1437 Cmd::PersistProjectOutputStyle { style } => {
1438 let tx = self.msg_tx.clone();
1439 let workdir = self.workdir.clone();
1440 self.detached.spawn(async move {
1441 if let Err(err) = crate::app::persist_project_output_style(&workdir, &style) {
1442 let _ = tx
1443 .send(Msg::TransientStatus {
1444 text: format!("Couldn't save the project output style: {err}"),
1445 })
1446 .await;
1447 }
1448 });
1449 },
1450 Cmd::ComposeInEditor { .. } => {
1451 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1455 },
1456 Cmd::ListMemory => {
1457 let tx = self.msg_tx.clone();
1458 let workdir = self.workdir.clone();
1459 self.detached.spawn(async move {
1460 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1461 let text = match crate::app::memory::load(&workdir, &cfg) {
1462 Some(mem) => mem.index,
1463 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(),
1464 };
1465 let _ = tx.send(Msg::RuntimeText(text)).await;
1466 });
1467 },
1468 Cmd::RememberMemory { text } => {
1469 let tx = self.msg_tx.clone();
1470 let workdir = self.workdir.clone();
1471 self.detached.spawn(async move {
1472 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1473 let name = memory_title_from_text(&text);
1474 let status = match crate::app::memory::write_memory(
1475 &workdir,
1476 mermaid_domain::MemoryScope::ProjectPrivate,
1477 &name,
1478 &text,
1479 &[],
1480 &text,
1481 ) {
1482 Ok(_) => format!("Remembered: {name}"),
1483 Err(e) => format!("Couldn't save memory: {e}"),
1484 };
1485 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1486 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1487 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1488 });
1489 },
1490 Cmd::ForgetMemory { id } => {
1491 let tx = self.msg_tx.clone();
1492 let workdir = self.workdir.clone();
1493 self.detached.spawn(async move {
1494 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1495 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1496 Ok(Some(_)) => format!("Forgot: {id}"),
1497 Ok(None) => format!("No memory named '{id}'"),
1498 Err(e) => format!("Couldn't forget memory: {e}"),
1499 };
1500 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1501 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1502 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1503 });
1504 },
1505 Cmd::ConsolidateMemory { model_id } => {
1506 let tx = self.msg_tx.clone();
1507 let workdir = self.workdir.clone();
1508 let providers = self.providers.clone();
1509 self.detached.spawn(async move {
1510 consolidate_memory(tx, providers, workdir, model_id).await;
1511 });
1512 },
1513 Cmd::Query(query) => self.dispatch_query(query),
1514 Cmd::ShowRuntimeProcessLogs { id } => {
1515 let tx = self.msg_tx.clone();
1516 self.detached.spawn_blocking(move || {
1517 let text = crate::runtime_client::RuntimeClient::auto()
1518 .process_log(&id, None)
1519 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1520 .unwrap_or_else(|err| format!("Process log error: {err}"));
1521 let _ = tx.blocking_send(Msg::RuntimeText(text));
1522 });
1523 },
1524 Cmd::StopRuntimeProcess { id } => {
1525 let tx = self.msg_tx.clone();
1526 self.detached.spawn_blocking(move || {
1527 let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1528 Ok(response) => Msg::TransientStatus {
1529 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1530 },
1531 Err(err) => Msg::TransientStatus {
1532 text: format!("Process stop failed: {err}"),
1533 },
1534 };
1535 let _ = tx.blocking_send(msg);
1536 });
1537 },
1538 Cmd::KillBackgroundAgent { agent_id } => {
1539 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1543 if let Some(spawner) = spawner {
1544 match agent_id {
1545 Some(id) => {
1546 if let crate::providers::tool::subagent::KillResult::Evicted(
1551 workspace,
1552 ) = spawner.kill_detached(&id)
1553 {
1554 self.detached.spawn(async move {
1558 workspace.discard().await;
1559 });
1560 }
1561 },
1562 None => {
1563 spawner.kill_all_detached();
1564 },
1565 }
1566 }
1567 },
1568 Cmd::RestartRuntimeProcess { id } => {
1569 let tx = self.msg_tx.clone();
1570 self.detached.spawn_blocking(move || {
1571 let msg = match crate::runtime_client::RuntimeClient::auto()
1572 .restart_process(&id)
1573 {
1574 Ok(response) => Msg::TransientStatus {
1575 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1576 },
1577 Err(err) => Msg::TransientStatus {
1578 text: format!("Process restart failed: {err}"),
1579 },
1580 };
1581 let _ = tx.blocking_send(msg);
1582 });
1583 },
1584 Cmd::OpenRuntimeTarget { target } => {
1585 self.detached.spawn_blocking(move || {
1586 let resolved = crate::runtime_client::RuntimeService::open_default()
1587 .and_then(|service| service.resolve_open_target(&target))
1588 .unwrap_or(target);
1589 if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1593 tracing::warn!(error = %err, "refusing to open runtime target");
1594 return;
1595 }
1596 mermaid_model::utils::open_file(resolved);
1597 });
1598 },
1599 Cmd::ShowRuntimePorts => {
1600 let tx = self.msg_tx.clone();
1601 self.detached.spawn_blocking(move || {
1602 let text = crate::runtime_client::RuntimeClient::auto()
1603 .ports()
1604 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1605 .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1606 let _ = tx.blocking_send(Msg::RuntimeText(text));
1607 });
1608 },
1609 Cmd::DecideRuntimeApproval { id, decision } => {
1610 let tx = self.msg_tx.clone();
1611 self.detached.spawn_blocking(move || {
1612 let result = if decision == "approved" {
1613 crate::runtime_client::RuntimeClient::auto().approve(&id)
1614 } else {
1615 crate::runtime_client::RuntimeClient::auto().deny(&id)
1616 };
1617 let msg = match result {
1618 Ok(result) => Msg::TransientStatus {
1619 text: if result.replayed {
1620 format!("Approval {} {}: {}", id, decision, result.summary)
1621 } else {
1622 format!("Approval {id} {decision}")
1623 },
1624 },
1625 Err(err) => Msg::TransientStatus {
1626 text: format!("Approval update failed: {err}"),
1627 },
1628 };
1629 let _ = tx.blocking_send(msg);
1630 });
1631 },
1632 Cmd::UpdateRuntimeTaskStatus {
1633 id,
1634 status,
1635 final_report,
1636 } => {
1637 let tx = self.msg_tx.clone();
1638 self.detached.spawn_blocking(move || {
1639 let msg = match mermaid_runtime::with_shared_store(|store| {
1640 store
1641 .tasks()
1642 .update_status(&id, status, final_report.as_deref())
1643 }) {
1644 Ok(()) => Msg::TransientStatus {
1645 text: format!("Task {id} -> {status}"),
1646 },
1647 Err(err) => Msg::TransientStatus {
1648 text: format!("Task update failed: {err}"),
1649 },
1650 };
1651 let _ = tx.blocking_send(msg);
1652 });
1653 },
1654 Cmd::CreateRuntimeCheckpoint { paths } => {
1655 let tx = self.msg_tx.clone();
1656 let workdir = self.workdir.clone();
1657 self.detached.spawn_blocking(move || {
1658 let pending_action = Some(serde_json::json!({
1659 "source": "tui",
1660 "command": "checkpoint",
1661 }));
1662 let msg = match mermaid_runtime::create_checkpoint(
1663 &workdir,
1664 &paths,
1665 pending_action,
1666 ) {
1667 Ok(manifest) => Msg::TransientStatus {
1668 text: format!(
1669 "Checkpoint {} created for {} path(s)",
1670 manifest.id,
1671 manifest.files.len()
1672 ),
1673 },
1674 Err(err) => Msg::TransientStatus {
1675 text: format!("Checkpoint failed: {err}"),
1676 },
1677 };
1678 let _ = tx.blocking_send(msg);
1679 });
1680 },
1681 Cmd::RestoreRuntimeCheckpoint { id } => {
1682 let tx = self.msg_tx.clone();
1683 self.detached.spawn_blocking(move || {
1684 let msg = match crate::runtime_client::RuntimeClient::auto()
1685 .restore_checkpoint(&id)
1686 {
1687 Ok(result) => Msg::TransientStatus {
1688 text: format!(
1689 "Restored checkpoint {} ({} file(s)){}",
1690 result.checkpoint.id,
1691 result.checkpoint.files.len(),
1692 if result.checkpoint.pending_action.is_some() {
1693 "; pending action available in checkpoint manifest"
1694 } else {
1695 ""
1696 }
1697 ),
1698 },
1699 Err(err) => Msg::TransientStatus {
1700 text: format!("Restore failed: {err}"),
1701 },
1702 };
1703 let _ = tx.blocking_send(msg);
1704 });
1705 },
1706 Cmd::ShowRuntimeModelInfo { model } => {
1707 let tx = self.msg_tx.clone();
1708 self.detached.spawn_blocking(move || {
1709 let text = runtime_model_info_text(&model);
1710 let _ = tx.blocking_send(Msg::RuntimeText(text));
1711 });
1712 },
1713 Cmd::InitMcpServers(configs) => {
1714 let tx = self.msg_tx.clone();
1715 self.detached
1716 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1717 },
1718 Cmd::StopMcpServer { name } => {
1719 let tx = self.msg_tx.clone();
1720 self.detached.spawn(async move {
1721 if let Some(mgr) = crate::mcp::manager_ref::get() {
1724 mgr.stop_server(&name).await;
1725 }
1726 let _ = tx.send(Msg::McpServerStopped { name }).await;
1727 });
1728 },
1729 Cmd::PullOllamaModel { model } => {
1730 let tx = self.msg_tx.clone();
1731 self.detached.spawn(async move {
1732 dispatch_pull_ollama_model(tx, model).await;
1733 });
1734 },
1735 Cmd::OpenInSystem(path) => {
1736 self.detached.spawn(async move {
1737 let _ = tokio::task::spawn_blocking(move || {
1738 mermaid_model::utils::open_file(&path);
1739 })
1740 .await;
1741 });
1742 },
1743 Cmd::WriteImageToTemp {
1744 path,
1745 bytes,
1746 format: _,
1747 } => {
1748 self.detached.spawn(async move {
1749 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1750 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1751 }
1752 });
1753 },
1754 Cmd::ReadClipboard => {
1755 let tx = self.msg_tx.clone();
1756 self.detached.spawn(async move {
1757 dispatch_read_clipboard(tx).await;
1758 });
1759 },
1760 Cmd::ProbeVision { model_id, warn } => {
1761 let tx = self.msg_tx.clone();
1762 let providers = self.providers.clone();
1763 self.detached.spawn(async move {
1764 dispatch_probe_vision(model_id, warn, providers, tx).await;
1765 });
1766 },
1767 Cmd::CopyToClipboard(text) => {
1768 let tx = self.msg_tx.clone();
1769 self.detached.spawn(async move {
1770 dispatch_copy_to_clipboard(text, tx).await;
1771 });
1772 },
1773 Cmd::Exit => {
1774 },
1779 Cmd::SetTerminalTitle(title) => {
1780 if !self.terminal_title_enabled {
1781 return;
1782 }
1783 self.detached.spawn_blocking(move || {
1789 use std::io::Write;
1790 let seq = format!("\x1b]2;{title}\x07");
1791 let mut stdout = std::io::stdout();
1792 let _ = stdout.write_all(seq.as_bytes());
1793 let _ = stdout.flush();
1794 });
1795 },
1796 Cmd::AlertUser => {
1797 if !self.terminal_title_enabled {
1798 return;
1799 }
1800 self.detached.spawn_blocking(|| {
1803 use std::io::Write;
1804 let mut stdout = std::io::stdout();
1805 let _ = stdout.write_all(b"\x07");
1806 let _ = stdout.flush();
1807 });
1808 },
1809 }
1810 }
1811
1812 fn queue_persistence(&mut self, job: PersistenceJob) {
1813 let previous = self.persistence_tail.take();
1814 let state = Arc::clone(&self.persistence_state);
1815 let tx = self.msg_tx.clone();
1816 self.persistence_tail = Some(tokio::spawn(async move {
1817 if let Some(previous) = previous
1818 && let Err(error) = previous.await
1819 {
1820 tracing::warn!(error = %error, "previous persistence job panicked");
1821 }
1822
1823 let result = tokio::task::spawn_blocking(move || {
1824 state
1825 .lock()
1826 .unwrap_or_else(|error| error.into_inner())
1827 .process(job)
1828 })
1829 .await;
1830
1831 match result {
1832 Ok((events, outcome)) => {
1833 if outcome.is_ok() || !events.is_empty() {
1837 let _ = tx.send(Msg::SessionSaved).await;
1838 }
1839 for event in events {
1840 fire_compaction_hook(&event).await;
1841 }
1842 if let Err(error) = outcome {
1843 tracing::warn!(
1844 error = %error,
1845 "persistence job failed; compaction barriers remain queued"
1846 );
1847 }
1848 },
1849 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1850 }
1851 }));
1852 }
1853
1854 pub async fn shutdown(mut self) {
1858 for (id, scope) in self.scopes.iter() {
1859 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1860 scope.cancel();
1861 }
1862
1863 if let Some(handle) = self.config_watch.take() {
1866 handle.abort();
1867 }
1868
1869 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1871
1872 let owns_global_mcp = self.owns_global_mcp;
1873 let persistence_tail = self.persistence_tail.take();
1874 let persistence_state = Arc::clone(&self.persistence_state);
1875 let drain = async {
1876 if let Some(tail) = persistence_tail
1877 && let Err(error) = tail.await
1878 {
1879 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1880 }
1881 match tokio::task::spawn_blocking(move || {
1882 let mut state = persistence_state
1883 .lock()
1884 .unwrap_or_else(|error| error.into_inner());
1885 let drained = state.retry_all_blocked();
1886 if let Err(error) = state.flush_checkpoints() {
1890 tracing::warn!(
1891 error = %error,
1892 "shutdown: could not flush a session checkpoint; the log still has everything, so the next resume just folds further"
1893 );
1894 }
1895 drop(state);
1896 drained
1897 })
1898 .await
1899 {
1900 Ok((events, outcome)) => {
1901 for event in events {
1904 fire_compaction_hook(&event).await;
1905 }
1906 if let Err(error) = outcome {
1907 tracing::warn!(
1908 error = %error,
1909 "shutdown: compaction persistence barrier retry failed"
1910 );
1911 }
1912 },
1913 Err(error) => tracing::warn!(
1914 error = %error,
1915 "shutdown: compaction persistence barrier panicked"
1916 ),
1917 }
1918 if owns_global_mcp {
1922 let _ = tokio::time::timeout(
1928 std::time::Duration::from_secs(2),
1929 crate::mcp::manager_ref::wait_ready(),
1930 )
1931 .await;
1932 if let Some(mgr) = crate::mcp::manager_ref::get() {
1939 mgr.shutdown().await;
1940 }
1941 crate::searxng::shutdown().await;
1945 }
1946 for (id, mut scope) in self.scopes.drain() {
1952 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1953 .await
1954 .is_err()
1955 {
1956 tracing::warn!(
1957 turn = %id,
1958 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1959 "shutdown: scope drain timed out; aborting its remaining tasks"
1960 );
1961 }
1962 }
1963 while let Some(result) = self.detached.join_next().await {
1964 if let Err(e) = result
1965 && !e.is_cancelled()
1966 {
1967 tracing::warn!(error = %e, "shutdown: detached task panic");
1968 }
1969 }
1970 };
1971
1972 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1973 }
1974}
1975
1976fn note_stream_usage(
1984 tasks: &crate::providers::TaskBroker,
1985 usage: &Option<mermaid_model::models::TokenUsage>,
1986) {
1987 if let Some(usage) = usage {
1988 tasks.add_tokens(usage.completion_tokens as u64);
1989 }
1990}
1991
1992mod compaction;
1993mod memory;
1994mod model_call;
1995mod tool_call;
1996
1997use compaction::*;
1998use memory::*;
1999use model_call::*;
2000use tool_call::*;
2001
2002#[cfg(test)]
2003mod tests {
2004 #[test]
2008 fn compaction_row_maps_every_field_it_claims_to() {
2009 use mermaid_domain::{CompactionEvent, CompactionTrigger};
2010 let record = CompactionEvent {
2011 id: "cmp-1".to_string(),
2012 trigger: CompactionTrigger::Manual,
2013 created_at: chrono::Local::now(),
2014 before_tokens: 9_000,
2015 after_tokens: 1_200,
2016 archived_message_count: 40,
2017 preserved_message_count: 6,
2018 preserved_turn_count: 3,
2019 summary_tokens: 450,
2020 duration_secs: 1.5,
2021 focus: None,
2022 archive_path: None,
2023 };
2024 let row = compaction_row(
2025 &record,
2026 std::path::Path::new("/tmp/archive.json"),
2027 Some("task-7".to_string()),
2028 "sess-3".to_string(),
2029 );
2030 assert_eq!(row.id.as_deref(), Some("cmp-1"));
2031 assert_eq!(row.task_id.as_deref(), Some("task-7"));
2032 assert_eq!(row.session_id.as_deref(), Some("sess-3"));
2033 assert_eq!(row.source_token_estimate, Some(9_000));
2034 assert_eq!(row.summary_token_count, Some(450));
2035 assert_eq!(row.preserved_turns, Some(3));
2036 assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
2037 }
2038
2039 use super::*;
2040 use mermaid_domain::ToolCallId;
2041 use std::time::Duration;
2042
2043 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2044 EffectRunner::pair(PathBuf::from("/tmp"))
2045 }
2046
2047 #[test]
2048 fn runtime_tool_payloads_are_redacted_before_serialization() {
2049 let payload = serde_json::json!({
2050 "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
2051 "authorization": "opaque-secret-value",
2052 "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
2053 });
2054 let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
2055 assert!(
2056 !serialized.contains("hunter2"),
2057 "URL password leaked: {serialized}"
2058 );
2059 assert!(
2060 !serialized.contains("opaque-signature"),
2061 "signed URL leaked: {serialized}"
2062 );
2063 assert!(
2064 !serialized.contains("private"),
2065 "URL fragment leaked: {serialized}"
2066 );
2067 assert!(
2068 !serialized.contains("opaque-secret-value"),
2069 "credential-named field leaked: {serialized}"
2070 );
2071 assert!(
2072 !serialized.contains("abcdef123456ghijkl"),
2073 "bearer token leaked: {serialized}"
2074 );
2075 assert!(
2076 !serialized.contains("sk-abcdefghijklmnop1234"),
2077 "secret-shaped fetched content leaked: {serialized}"
2078 );
2079 assert!(serialized.contains("[REDACTED]"));
2080 }
2081
2082 #[test]
2083 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
2084 let root = std::env::temp_dir().join(format!(
2085 "mermaid-walk-{}-{:?}",
2086 std::process::id(),
2087 std::thread::current().id()
2088 ));
2089 let _ = std::fs::remove_dir_all(&root);
2090 std::fs::create_dir_all(root.join("src")).unwrap();
2091 std::fs::create_dir_all(root.join("target")).unwrap();
2092 std::fs::create_dir_all(root.join(".git")).unwrap();
2093 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
2094 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2095 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
2096 std::fs::write(root.join("README.md"), "readme").unwrap();
2097 std::fs::write(root.join(".hidden"), "hidden").unwrap();
2098
2099 let files = walk_project_files(&root);
2100 assert_eq!(
2101 files,
2102 vec![
2103 "README.md".to_string(),
2104 "src/".to_string(),
2105 "src/main.rs".to_string(),
2106 ],
2107 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
2108 );
2109 let _ = std::fs::remove_dir_all(&root);
2110 }
2111
2112 #[test]
2113 fn new_child_suppresses_terminal_title() {
2114 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2118 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2119 let tools = Arc::new(ToolRegistry::new());
2120 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2121 assert!(
2122 !child.terminal_title_enabled,
2123 "subagent child runner must suppress terminal-title escapes"
2124 );
2125 }
2126
2127 #[test]
2128 fn new_child_does_not_own_global_mcp_shutdown() {
2129 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2134 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2135 let tools = Arc::new(ToolRegistry::new());
2136 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2137 assert!(
2138 !child.owns_global_mcp,
2139 "child runner must not reap the shared global MCP manager"
2140 );
2141 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2142 assert!(
2143 top.owns_global_mcp,
2144 "top-level runner still owns the global MCP reap"
2145 );
2146 }
2147
2148 #[test]
2149 fn parse_prune_plan_extracts_json_amid_prose() {
2150 let plan = parse_prune_plan(
2151 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2152 )
2153 .expect("should parse");
2154 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2155 assert_eq!(plan.reason, "dupes");
2156 }
2157
2158 #[test]
2159 fn parse_prune_plan_handles_empty_and_garbage() {
2160 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2161 .expect("empty plan parses");
2162 assert!(empty.prune.is_empty());
2163 assert!(parse_prune_plan("no json here").is_none());
2164 }
2165
2166 #[test]
2167 fn memory_title_from_text_is_short_and_nonempty() {
2168 assert_eq!(
2169 memory_title_from_text("prefer ripgrep over grep"),
2170 "prefer ripgrep over grep"
2171 );
2172 assert_eq!(memory_title_from_text(" "), "memory");
2173 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2174 assert!(long.split_whitespace().count() <= 8);
2175 }
2176
2177 #[tokio::test]
2178 async fn dispatch_exit_is_noop_on_runner_state() {
2179 let (mut r, _rx) = runner();
2180 r.dispatch(Cmd::Exit);
2181 assert_eq!(r.scope_count(), 0);
2182 }
2183
2184 #[tokio::test]
2185 async fn dispatch_save_emits_session_saved() {
2186 let (mut r, mut rx) = runner();
2187 r.dispatch(Cmd::SaveConversation {
2188 snapshot: mermaid_domain::ConversationHistory::new(
2189 "/p".to_string(),
2190 "m".to_string(),
2191 chrono::Local::now(),
2192 ),
2193 events: Vec::new(),
2194 });
2195 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2196 .await
2197 .expect("sender emits")
2198 .expect("channel alive");
2199 assert!(matches!(msg, Msg::SessionSaved));
2200 }
2201
2202 #[cfg(unix)]
2203 #[tokio::test]
2204 async fn init_mcp_servers_emits_incremental_errored_msgs() {
2205 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2209 let mut configs = std::collections::HashMap::new();
2210 for name in ["one", "two"] {
2211 configs.insert(
2212 name.to_string(),
2213 mermaid_domain::McpServerConfig {
2214 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2215 ..Default::default()
2216 },
2217 );
2218 }
2219 dispatch_init_mcp_servers(configs, tx).await;
2220 let mut errored = Vec::new();
2221 while let Ok(msg) = rx.try_recv() {
2222 match msg {
2223 Msg::McpServerErrored { name, .. } => errored.push(name),
2224 other => panic!("unexpected msg: {other:?}"),
2225 }
2226 }
2227 errored.sort();
2228 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2229 assert!(crate::mcp::manager_ref::is_ready());
2230 }
2231
2232 #[tokio::test]
2233 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2234 let (mut r, mut rx) = runner();
2235 let turn = TurnId(77);
2236 {
2237 let scope = r.scope_mut(turn);
2238 scope.spawn(async {
2239 std::future::pending::<()>().await;
2240 });
2241 }
2242 assert_eq!(r.scope_count(), 1);
2243
2244 let start = std::time::Instant::now();
2245 r.dispatch(Cmd::CancelScope(turn));
2246 assert_eq!(r.scope_count(), 0);
2247 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2248 .await
2249 .expect("bounded cancel should emit terminal message")
2250 .expect("channel alive");
2251 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2252 assert!(
2253 start.elapsed() < Duration::from_millis(500),
2254 "cancel terminal message took {:?}",
2255 start.elapsed()
2256 );
2257 }
2258
2259 #[tokio::test]
2260 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2261 let (mut r, mut rx) = runner();
2267 let turn = TurnId(88);
2268 {
2269 let scope = r.scope_mut(turn);
2270 scope.spawn(async {}); }
2272 assert_eq!(r.scope_count(), 1);
2273
2274 tokio::time::sleep(Duration::from_millis(20)).await;
2276 r.dispatch(Cmd::Exit);
2277 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2278
2279 r.dispatch(Cmd::CancelScope(turn));
2282 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2283 .await
2284 .expect("cancel on a reaped scope must still emit a terminal message")
2285 .expect("channel alive");
2286 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2287 }
2288
2289 #[tokio::test]
2290 async fn dispatch_call_model_creates_scope() {
2291 let (mut r, _rx) = runner();
2292 let turn = TurnId(7);
2293 let request = mermaid_domain::ChatRequest {
2294 model_id: "test/m".to_string(),
2295 messages: vec![],
2296 system_prompt: String::new(),
2297 instructions: None,
2298 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2299 temperature: 0.7,
2300 max_tokens: 4096,
2301 tools: vec![],
2302
2303 ollama_num_ctx: None,
2304 ollama_allow_ram_offload: None,
2305 resolved_context_window: None,
2306 resolved_max_output: None,
2307 output_schema: None,
2308 suppress_auto_compact: false,
2309 requested_compaction: None,
2310 native_compaction: None,
2311 };
2312 r.dispatch(Cmd::CallModel { turn, request });
2313 assert_eq!(r.scope_count(), 1);
2314 }
2315
2316 #[tokio::test]
2320 async fn empty_scopes_are_reaped_on_next_dispatch() {
2321 let (mut r, mut rx) = runner();
2322 let turn = TurnId(42);
2323 let request = mermaid_domain::ChatRequest {
2324 model_id: "test/m".to_string(),
2325 messages: vec![],
2326 system_prompt: String::new(),
2327 instructions: None,
2328 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2329 temperature: 0.7,
2330 max_tokens: 4096,
2331 tools: vec![],
2332
2333 ollama_num_ctx: None,
2334 ollama_allow_ram_offload: None,
2335 resolved_context_window: None,
2336 resolved_max_output: None,
2337 output_schema: None,
2338 suppress_auto_compact: false,
2339 requested_compaction: None,
2340 native_compaction: None,
2341 };
2342 r.dispatch(Cmd::CallModel { turn, request });
2343 assert_eq!(r.scope_count(), 1);
2344
2345 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2350 .await
2351 .expect("upstream error arrived")
2352 .expect("channel alive");
2353 assert!(matches!(msg, Msg::UpstreamError { .. }));
2354
2355 tokio::task::yield_now().await;
2357
2358 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2360 assert_eq!(
2361 r.scope_count(),
2362 0,
2363 "completed scope must be reaped on next dispatch"
2364 );
2365 }
2366
2367 #[tokio::test]
2368 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2369 let (mut r, mut rx) = runner();
2370 let turn = TurnId(7);
2371 let call_id = ToolCallId(1);
2372 let source = mermaid_model::models::tool_call::ToolCall {
2373 id: Some("c1".to_string()),
2374 function: mermaid_model::models::tool_call::FunctionCall {
2375 name: "read_file".to_string(),
2376 arguments: serde_json::json!({"path": "x"}),
2377 },
2378 };
2379 r.dispatch(Cmd::ExecuteTool {
2380 turn,
2381 call_id,
2382 source,
2383 dispatch: mermaid_domain::ToolDispatch {
2384 model_id: "ollama/test".to_string(),
2385 safety_mode: mermaid_runtime::SafetyMode::Ask,
2386 intent: None,
2387 session_id: "sess-test".to_string(),
2388 message_index: 0,
2389 scratchpad: None,
2390 },
2391 });
2392 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2393 .await
2394 .expect("some msg")
2395 .expect("channel alive");
2396 assert!(matches!(
2397 first,
2398 Msg::ToolStarted {
2399 turn: t,
2400 call_id: c,
2401 } if t == turn && c == call_id
2402 ));
2403 }
2404
2405 #[tokio::test]
2406 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2407 let (mut r, _rx) = runner();
2408 let turn = TurnId(9);
2409 r.dispatch(Cmd::CallModel {
2410 turn,
2411 request: mermaid_domain::ChatRequest {
2412 model_id: "m".to_string(),
2413 messages: vec![],
2414 system_prompt: String::new(),
2415 instructions: None,
2416 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2417 temperature: 0.7,
2418 max_tokens: 4096,
2419 tools: vec![],
2420
2421 ollama_num_ctx: None,
2422 ollama_allow_ram_offload: None,
2423 resolved_context_window: None,
2424 resolved_max_output: None,
2425 output_schema: None,
2426 suppress_auto_compact: false,
2427 requested_compaction: None,
2428 native_compaction: None,
2429 },
2430 });
2431 assert_eq!(r.scope_count(), 1);
2432
2433 r.dispatch(Cmd::CancelScope(turn));
2434 assert_eq!(r.scope_count(), 0);
2435 }
2436
2437 #[tokio::test]
2438 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2439 let (mut r, _rx) = runner();
2445 let req = || mermaid_domain::ChatRequest {
2446 model_id: "test/m".to_string(),
2447 messages: vec![],
2448 system_prompt: String::new(),
2449 instructions: None,
2450 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2451 temperature: 0.7,
2452 max_tokens: 4096,
2453 tools: vec![],
2454 ollama_num_ctx: None,
2455 ollama_allow_ram_offload: None,
2456 resolved_context_window: None,
2457 resolved_max_output: None,
2458 output_schema: None,
2459 suppress_auto_compact: false,
2460 requested_compaction: None,
2461 native_compaction: None,
2462 };
2463 let turn = TurnId(123);
2464
2465 r.dispatch(Cmd::CallModel {
2466 turn,
2467 request: req(),
2468 });
2469 assert_eq!(r.scope_count(), 1);
2470
2471 r.dispatch(Cmd::CancelScope(turn));
2473 assert_eq!(r.scope_count(), 0);
2474
2475 r.dispatch(Cmd::CallModel {
2477 turn,
2478 request: req(),
2479 });
2480 assert_eq!(
2481 r.scope_count(),
2482 0,
2483 "a cancelled turn must not be resurrected by a late scoped Cmd"
2484 );
2485
2486 r.dispatch(Cmd::CallModel {
2488 turn: TurnId(124),
2489 request: req(),
2490 });
2491 assert_eq!(
2492 r.scope_count(),
2493 1,
2494 "a fresh turn must still create its scope normally"
2495 );
2496 }
2497
2498 #[tokio::test]
2499 async fn shutdown_drains_pending_saves() {
2500 let (mut r, _rx) = runner();
2501 for _ in 0..5 {
2502 r.dispatch(Cmd::SaveConversation {
2503 snapshot: mermaid_domain::ConversationHistory::new(
2504 "/p".to_string(),
2505 "m".to_string(),
2506 chrono::Local::now(),
2507 ),
2508 events: Vec::new(),
2509 });
2510 }
2511 let start = std::time::Instant::now();
2513 r.shutdown().await;
2514 assert!(start.elapsed() < Duration::from_secs(2));
2515 }
2516
2517 fn persistence_fixture(
2518 root: &std::path::Path,
2519 record_id: &str,
2520 ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2521 let now = chrono::Local::now();
2522 let mut full = mermaid_domain::ConversationHistory::new(
2523 root.display().to_string(),
2524 "test/model".to_string(),
2525 now,
2526 );
2527 full.add_messages(
2528 &[mermaid_model::models::ChatMessage::user("raw history")],
2529 now,
2530 );
2531 let mut compacted = full.clone();
2532 compacted.replace_messages(
2533 vec![mermaid_model::models::ChatMessage::user(
2534 "compacted checkpoint",
2535 )],
2536 now,
2537 );
2538 let record = mermaid_domain::CompactionEvent {
2539 id: record_id.to_string(),
2540 trigger: mermaid_domain::CompactionTrigger::Manual,
2541 created_at: now,
2542 before_tokens: 100,
2543 after_tokens: 20,
2544 archived_message_count: 1,
2545 preserved_message_count: 1,
2546 preserved_turn_count: 1,
2547 summary_tokens: 10,
2548 duration_secs: 0.1,
2549 focus: None,
2550 archive_path: None,
2551 };
2552 (
2553 full,
2554 PendingCompactionSave {
2555 record,
2556 conversation: compacted,
2557 events: vec![mermaid_domain::SessionEvent::Input {
2560 text: "compaction boundary".to_string(),
2561 }],
2562 events_appended: false,
2563 task_id: None,
2564 },
2565 )
2566 }
2567
2568 fn block_event_log(root: &std::path::Path, id: &str) {
2573 let dir = root.join(".mermaid").join("conversations");
2574 std::fs::create_dir_all(&dir).expect("conversations dir");
2575 std::fs::create_dir_all(dir.join(format!("{id}.jsonl"))).expect("plant a blocker");
2576 }
2577
2578 fn one_message_save(
2580 conversation: &mut mermaid_domain::ConversationHistory,
2581 text: &str,
2582 ) -> PersistenceJob {
2583 let message = mermaid_model::models::ChatMessage::user(text);
2584 conversation.add_messages(std::slice::from_ref(&message), chrono::Local::now());
2585 PersistenceJob::Conversation {
2586 snapshot: Box::new(conversation.clone()),
2587 events: vec![mermaid_domain::SessionEvent::Message { message }],
2588 }
2589 }
2590
2591 #[test]
2592 fn the_checkpoint_stops_being_written_on_every_save() {
2593 let root = std::env::temp_dir().join(format!(
2597 "mermaid-throttle-{}-{:?}",
2598 std::process::id(),
2599 std::thread::current().id()
2600 ));
2601 let _ = std::fs::remove_dir_all(&root);
2602 let manager = crate::session::ConversationManager::new(&root).unwrap();
2603 let mut conversation = mermaid_domain::ConversationHistory::new(
2604 root.display().to_string(),
2605 "test/model".to_string(),
2606 chrono::Local::now(),
2607 );
2608 let mut state = PersistenceState::new(root.clone());
2609
2610 state
2613 .process(one_message_save(&mut conversation, "first"))
2614 .1
2615 .unwrap();
2616 let checkpoint = manager
2617 .conversations_dir()
2618 .join(format!("{}.json", conversation.id));
2619 assert!(
2620 !checkpoint.exists(),
2621 "a single save must not rewrite the transcript"
2622 );
2623
2624 let resumed = manager.load_conversation(&conversation.id).unwrap();
2626 assert_eq!(resumed.messages().len(), 1);
2627 assert_eq!(resumed.messages()[0].content, "first");
2628
2629 for i in 0..CHECKPOINT_EVERY_EVENTS {
2631 state
2632 .process(one_message_save(&mut conversation, &format!("m{i}")))
2633 .1
2634 .unwrap();
2635 }
2636 assert!(
2637 checkpoint.exists(),
2638 "crossing {CHECKPOINT_EVERY_EVENTS} events must materialize a checkpoint"
2639 );
2640 let resumed = manager.load_conversation(&conversation.id).unwrap();
2641 assert_eq!(resumed.messages().len(), CHECKPOINT_EVERY_EVENTS + 1);
2642 let _ = std::fs::remove_dir_all(root);
2643 }
2644
2645 #[test]
2646 fn shutdown_flushes_the_checkpoint_it_was_holding() {
2647 let root = std::env::temp_dir().join(format!(
2648 "mermaid-flush-{}-{:?}",
2649 std::process::id(),
2650 std::thread::current().id()
2651 ));
2652 let _ = std::fs::remove_dir_all(&root);
2653 let manager = crate::session::ConversationManager::new(&root).unwrap();
2654 let mut conversation = mermaid_domain::ConversationHistory::new(
2655 root.display().to_string(),
2656 "test/model".to_string(),
2657 chrono::Local::now(),
2658 );
2659 let mut state = PersistenceState::new(root.clone());
2660 state
2661 .process(one_message_save(&mut conversation, "only message"))
2662 .1
2663 .unwrap();
2664
2665 let checkpoint = manager
2666 .conversations_dir()
2667 .join(format!("{}.json", conversation.id));
2668 assert!(!checkpoint.exists());
2669 state.flush_checkpoints().unwrap();
2670 assert!(
2671 checkpoint.exists(),
2672 "a clean exit must leave a current checkpoint"
2673 );
2674 let raw = std::fs::read_to_string(&checkpoint).unwrap();
2676 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2677 assert!(
2678 value.get("checkpoint_seq").is_some(),
2679 "a flushed checkpoint must be placeable in its log: {raw}"
2680 );
2681 let _ = std::fs::remove_dir_all(root);
2682 }
2683
2684 #[test]
2685 fn a_failed_append_keeps_its_events_for_the_next_save() {
2686 let root = std::env::temp_dir().join(format!(
2689 "mermaid-unappended-{}-{:?}",
2690 std::process::id(),
2691 std::thread::current().id()
2692 ));
2693 let _ = std::fs::remove_dir_all(&root);
2694 let manager = crate::session::ConversationManager::new(&root).unwrap();
2695 let mut conversation = mermaid_domain::ConversationHistory::new(
2696 root.display().to_string(),
2697 "test/model".to_string(),
2698 chrono::Local::now(),
2699 );
2700 let mut state = PersistenceState::new(root.clone());
2701
2702 std::fs::create_dir_all(
2704 manager
2705 .conversations_dir()
2706 .join(format!("{}.jsonl", conversation.id)),
2707 )
2708 .expect("plant a blocker");
2709 let job = one_message_save(&mut conversation, "must survive");
2710 assert!(state.process(job).1.is_err(), "the append must fail");
2711 assert_eq!(
2712 state.unappended.get(&conversation.id).map(Vec::len),
2713 Some(1),
2714 "the batch must be held, not dropped"
2715 );
2716
2717 std::fs::remove_dir_all(
2719 manager
2720 .conversations_dir()
2721 .join(format!("{}.jsonl", conversation.id)),
2722 )
2723 .expect("unblock");
2724 state
2725 .process(one_message_save(&mut conversation, "and this one"))
2726 .1
2727 .unwrap();
2728 assert!(state.unappended.is_empty(), "the hold must clear");
2729 state.flush_checkpoints().unwrap();
2730
2731 let resumed = manager.load_conversation(&conversation.id).unwrap();
2732 let texts: Vec<&str> = resumed
2733 .messages()
2734 .iter()
2735 .map(|m| m.content.as_str())
2736 .collect();
2737 assert!(
2738 texts.contains(&"must survive"),
2739 "the event held over a failed append must reach the log: {texts:?}"
2740 );
2741 assert!(texts.contains(&"and this one"), "{texts:?}");
2742 let _ = std::fs::remove_dir_all(root);
2743 }
2744
2745 #[test]
2746 fn persistence_orders_compaction_before_newer_conversation_save() {
2747 let root = std::env::temp_dir().join(format!(
2748 "mermaid-persistence-order-{}-{:?}",
2749 std::process::id(),
2750 std::thread::current().id()
2751 ));
2752 let _ = std::fs::remove_dir_all(&root);
2753 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2754 let manager = crate::session::ConversationManager::new(&root).unwrap();
2755 manager.save_conversation(&full).unwrap();
2756
2757 let mut state = PersistenceState::new(root.clone());
2758 let (events, outcome) =
2759 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2760 outcome.unwrap();
2761 assert_eq!(events.len(), 1);
2762 let mut newer = compaction.conversation;
2763 let reply = mermaid_model::models::ChatMessage::assistant("new assistant reply");
2764 newer.add_messages(std::slice::from_ref(&reply), chrono::Local::now());
2765 let (_, outcome) = state.process(PersistenceJob::Conversation {
2770 snapshot: Box::new(newer),
2771 events: vec![mermaid_domain::SessionEvent::Message { message: reply }],
2772 });
2773 outcome.unwrap();
2774
2775 let loaded = crate::session::ConversationManager::new(&root)
2776 .unwrap()
2777 .load_conversation(&full.id)
2778 .unwrap();
2779 assert!(
2780 loaded
2781 .messages()
2782 .iter()
2783 .any(|message| message.content == "new assistant reply")
2784 );
2785 let _ = std::fs::remove_dir_all(root);
2786 }
2787
2788 #[test]
2789 fn failed_event_append_blocks_later_stripped_conversation_save() {
2790 let root = std::env::temp_dir().join(format!(
2791 "mermaid-persistence-barrier-{}-{:?}",
2792 std::process::id(),
2793 std::thread::current().id()
2794 ));
2795 let _ = std::fs::remove_dir_all(&root);
2796 let (full, compaction) = persistence_fixture(&root, "compact_blocked");
2797 let manager = crate::session::ConversationManager::new(&root).unwrap();
2798 manager.save_conversation(&full).unwrap();
2799 block_event_log(&root, &full.id);
2800
2801 let mut state = PersistenceState::new(root.clone());
2802 assert!(
2803 state
2804 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2805 .1
2806 .is_err()
2807 );
2808 assert!(
2809 state
2810 .process(PersistenceJob::Conversation {
2811 snapshot: Box::new(compaction.conversation),
2812 events: Vec::new(),
2813 })
2814 .1
2815 .is_err()
2816 );
2817 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2818
2819 let loaded = crate::session::ConversationManager::new(&root)
2820 .unwrap()
2821 .load_conversation(&full.id)
2822 .unwrap();
2823 assert_eq!(loaded.messages()[0].content, "raw history");
2824 let _ = std::fs::remove_dir_all(root);
2825 }
2826
2827 #[test]
2828 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2829 let root = std::env::temp_dir().join(format!(
2830 "mermaid-persistence-queue-{}-{:?}",
2831 std::process::id(),
2832 std::thread::current().id()
2833 ));
2834 let _ = std::fs::remove_dir_all(&root);
2835 let (full, first) = persistence_fixture(&root, "compact_first");
2836 let mut second = first.clone();
2837 second.record.id = "compact_second".to_string();
2838 block_event_log(&root, &full.id);
2839
2840 let mut state = PersistenceState::new(root.clone());
2841 assert!(
2842 state
2843 .process(PersistenceJob::Compaction(Box::new(first)))
2844 .1
2845 .is_err()
2846 );
2847 assert!(
2850 state
2851 .process(PersistenceJob::Compaction(Box::new(second)))
2852 .1
2853 .is_err()
2854 );
2855 let queued = state.blocked.get(&full.id).expect("barrier queue");
2856 assert_eq!(queued.len(), 2);
2857 assert_eq!(queued[0].record.id, "compact_first");
2858 assert_eq!(queued[1].record.id, "compact_second");
2859 let _ = std::fs::remove_dir_all(root);
2860 }
2861
2862 #[test]
2863 fn retry_all_blocked_attempts_every_conversation() {
2864 let root = std::env::temp_dir().join(format!(
2865 "mermaid-persistence-drain-{}-{:?}",
2866 std::process::id(),
2867 std::thread::current().id()
2868 ));
2869 let _ = std::fs::remove_dir_all(&root);
2870 let (bad_full, bad) = persistence_fixture(&root, "compact_bad");
2871 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2872 block_event_log(&root, &bad_full.id);
2873 good_full.id = "20990101_000000_001".to_string();
2877 good.conversation.id = good_full.id.clone();
2878
2879 let mut state = PersistenceState::new(root.clone());
2880 state
2881 .blocked
2882 .entry(bad_full.id.clone())
2883 .or_default()
2884 .push_back(bad);
2885 state
2886 .blocked
2887 .entry(good_full.id.clone())
2888 .or_default()
2889 .push_back(good);
2890
2891 let (events, outcome) = state.retry_all_blocked();
2895 assert!(outcome.is_err());
2896 assert_eq!(events.len(), 1);
2897 assert_eq!(events[0].id, "compact_good");
2898 assert!(!state.blocked.contains_key(&good_full.id));
2899 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2900 let loaded = crate::session::ConversationManager::new(&root)
2901 .unwrap()
2902 .load_conversation(&good_full.id)
2903 .unwrap();
2904 assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2905 let _ = std::fs::remove_dir_all(root);
2906 }
2907
2908 #[test]
2909 fn partially_drained_barrier_reports_its_persisted_events() {
2910 let root = std::env::temp_dir().join(format!(
2911 "mermaid-persistence-partial-{}-{:?}",
2912 std::process::id(),
2913 std::thread::current().id()
2914 ));
2915 let _ = std::fs::remove_dir_all(&root);
2916 let (full, good) = persistence_fixture(&root, "compact_good");
2917 let mut bad = good.clone();
2918 bad.record.id = "compact_bad".to_string();
2919 bad.conversation.id = "../invalid".to_string();
2922
2923 let mut state = PersistenceState::new(root.clone());
2924 let queue = state.blocked.entry(full.id.clone()).or_default();
2925 queue.push_back(good);
2926 queue.push_back(bad);
2927
2928 let (events, outcome) = state.retry_blocked(&full.id);
2932 assert!(outcome.is_err());
2933 assert_eq!(events.len(), 1);
2934 assert_eq!(events[0].id, "compact_good");
2935 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2936 let _ = std::fs::remove_dir_all(root);
2937 }
2938}