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(
1150 p.clone(),
1151 model,
1152 dispatch.reasoning,
1153 ))
1154 as Arc<dyn crate::providers::AutoClassifier>
1155 })
1156 } else {
1157 None
1158 };
1159 let services = crate::providers::ctx::ToolServices {
1160 workdir,
1161 config,
1162 task_id: self.task_id.clone(),
1163 notify: Some(self.msg_tx.clone()),
1167 classifier,
1168 approval: self.approval.clone(),
1169 questions: self.questions.clone(),
1170 tasks: Some(self.tasks.clone()),
1171 };
1172 let scope = self.scope_mut(turn);
1173 let signals = crate::providers::ctx::TurnSignals {
1174 token: scope.token(),
1175 background: scope.background_token(),
1176 web_bytes: scope.web_bytes(),
1177 };
1178 scope.spawn(async move {
1179 use futures::FutureExt;
1180 let fallback_tx = tx.clone();
1181 if std::panic::AssertUnwindSafe(dispatch_execute_tool(
1182 tx, tools, turn, call_id, source, signals, dispatch, services,
1183 ))
1184 .catch_unwind()
1185 .await
1186 .is_err()
1187 {
1188 tracing::error!(
1193 turn = %turn,
1194 call_id = call_id.0,
1195 "dispatch_execute_tool panicked"
1196 );
1197 let _ = fallback_tx
1198 .send(Msg::ToolFinished {
1199 turn,
1200 call_id,
1201 outcome: mermaid_domain::ToolOutcome::error(
1202 "internal error: the tool execution task panicked".to_string(),
1203 None,
1204 ),
1205 })
1206 .await;
1207 }
1208 });
1209 },
1210 Cmd::ResolveApproval { call_id, decision } => {
1211 if let Some(broker) = &self.approval {
1214 broker.resolve(call_id, decision.into());
1215 }
1216 },
1217 Cmd::ResolveQuestion {
1218 call_id,
1219 resolution,
1220 } => {
1221 if let Some(broker) = &self.questions {
1224 broker.resolve(call_id, resolution);
1225 }
1226 },
1227 Cmd::SyncTaskStore(store) => {
1228 self.tasks.seed(store);
1232 },
1233 Cmd::EnsureScratchpad { session_id } => {
1234 let tx = self.msg_tx.clone();
1235 let workdir = self.workdir.clone();
1236 self.detached.spawn(async move {
1237 match crate::session::scratchpad::ensure(&workdir, &session_id) {
1238 Ok(path) => {
1239 let _ = tx.send(Msg::ScratchpadReady { session_id, path }).await;
1240 },
1241 Err(err) => {
1242 tracing::warn!(error = %err, "failed to create session scratchpad");
1245 },
1246 }
1247 if let Err(err) = crate::session::scratchpad::sweep_stale(
1250 crate::session::scratchpad::RETENTION_DAYS,
1251 ) {
1252 tracing::warn!(error = %err, "scratchpad sweep failed");
1253 }
1254 });
1255 },
1256 Cmd::ListScratchpad { path } => {
1257 let tx = self.msg_tx.clone();
1260 self.detached.spawn(async move {
1261 let text = tokio::task::spawn_blocking(move || {
1262 crate::session::scratchpad::list_text(&path)
1263 })
1264 .await
1265 .unwrap_or_else(|e| format!("Couldn't list the scratchpad: {e}"));
1266 let _ = tx.send(Msg::RuntimeText(text)).await;
1267 });
1268 },
1269 Cmd::UserTaskEdit(edit) => {
1270 let broker = self.tasks.clone();
1275 let tx = self.msg_tx.clone();
1276 self.detached.spawn(async move {
1277 let (line, _snapshot) = broker.user_edit(edit).await;
1278 let _ = tx
1283 .send(Msg::TaskNotice {
1284 text: format!("The user edited the task checklist: {line}."),
1285 })
1286 .await;
1287 let _ = tx.send(Msg::TransientStatus { text: line }).await;
1288 });
1289 },
1290 Cmd::NotifyTaskCompleted {
1291 task,
1292 completed,
1293 total,
1294 } => {
1295 let payload = serde_json::json!({
1303 "task_id": task.id,
1304 "subject": task.subject,
1305 "description": task.description,
1306 "evidence": task.evidence,
1307 "completed": completed,
1308 "total": total,
1309 });
1310 let broker = self.tasks.clone();
1311 let tx = self.msg_tx.clone();
1312 self.detached.spawn(async move {
1313 let gate = run_plugin_hooks_gated("task_completed", payload).await;
1314 let Some((plugin, reason)) = gate.deny else {
1315 return;
1316 };
1317 let reason = mermaid_model::utils::redact_secrets(&reason);
1318 let _ = broker
1319 .update(vec![mermaid_domain::ChecklistEdit {
1320 id: task.id,
1321 status: Some(mermaid_domain::ChecklistStatus::InProgress),
1322 ..mermaid_domain::ChecklistEdit::default()
1323 }])
1324 .await;
1325 let _ = tx
1326 .send(Msg::TaskNotice {
1327 text: format!(
1328 "Completion of task #{} '{}' was vetoed by the {plugin} hook: \
1329 {reason}. The task is back in_progress; address the reason \
1330 before completing it again.",
1331 task.id, task.subject
1332 ),
1333 })
1334 .await;
1335 let _ = tx
1336 .send(Msg::TransientStatus {
1337 text: format!(
1338 "task #{} completion vetoed by {plugin}: {reason}",
1339 task.id
1340 ),
1341 })
1342 .await;
1343 });
1344 },
1345 Cmd::CancelScope(turn) => {
1346 self.drop_scope(turn);
1347 },
1348 Cmd::BackgroundScope(turn) => {
1349 self.scope_mut(turn).background();
1353 },
1354 Cmd::SaveConversation { snapshot, events } => {
1355 self.queue_persistence(PersistenceJob::Conversation {
1356 snapshot: Box::new(snapshot),
1357 events,
1358 });
1359 },
1360 Cmd::SaveCompaction {
1361 record,
1362 conversation,
1363 events,
1364 } => {
1365 self.queue_persistence(PersistenceJob::Compaction(Box::new(
1366 PendingCompactionSave {
1367 record,
1368 conversation,
1369 events,
1370 events_appended: false,
1371 task_id: self.task_id.clone(),
1372 },
1373 )));
1374 },
1375 Cmd::SaveProcess(process) => {
1376 let task_id = self.task_id.clone();
1377 self.detached.spawn(async move {
1378 let status = process.status;
1379 let _ = mermaid_runtime::with_shared_store(|store| {
1380 store.processes().upsert(mermaid_runtime::NewProcess {
1381 id: Some(process.id),
1382 task_id,
1383 pid: process.pid,
1384 command: process.command,
1385 cwd: process.cwd,
1386 log_path: Some(process.log_path),
1387 detected_url: process.detected_url,
1388 status,
1389 health: None,
1390 })
1391 });
1392 });
1393 },
1394 Cmd::PersistLastModel(model) => {
1395 self.detached.spawn(async move {
1396 if let Err(err) = crate::app::persist_last_model(&model) {
1397 tracing::warn!(error = %err, "failed to persist last-used model");
1398 }
1399 });
1400 },
1401 Cmd::PersistReasoningFor { model_id, level } => {
1402 self.detached.spawn(async move {
1403 if let Err(err) = crate::app::persist_reasoning_for_model(&model_id, level) {
1404 tracing::warn!(error = %err, "failed to persist reasoning level for model");
1405 }
1406 });
1407 },
1408 Cmd::PersistOllamaNumCtxFor { model_id, num_ctx } => {
1409 self.detached.spawn(async move {
1410 if let Err(err) =
1411 crate::app::persist_ollama_num_ctx_for_model(&model_id, num_ctx)
1412 {
1413 tracing::warn!(error = %err, "failed to persist Ollama num_ctx for model");
1414 }
1415 });
1416 },
1417 Cmd::PersistOllamaOffload(enabled) => {
1418 self.detached.spawn(async move {
1419 if let Err(err) = crate::app::persist_ollama_allow_ram_offload(enabled) {
1420 tracing::warn!(error = %err, "failed to persist Ollama RAM-offload setting");
1421 }
1422 });
1423 },
1424 Cmd::PersistUiTheme(theme) => {
1425 self.detached.spawn(async move {
1426 if let Err(err) = crate::app::persist_ui_theme(theme) {
1427 tracing::warn!(error = %err, "failed to persist theme");
1428 }
1429 });
1430 },
1431 Cmd::PersistOutputStyle { style } => {
1432 self.detached.spawn(async move {
1433 if let Err(err) = crate::app::persist_output_style(&style) {
1434 tracing::warn!(error = %err, "failed to persist output style");
1435 }
1436 });
1437 },
1438 Cmd::PersistProjectOutputStyle { style } => {
1439 let tx = self.msg_tx.clone();
1440 let workdir = self.workdir.clone();
1441 self.detached.spawn(async move {
1442 if let Err(err) = crate::app::persist_project_output_style(&workdir, &style) {
1443 let _ = tx
1444 .send(Msg::TransientStatus {
1445 text: format!("Couldn't save the project output style: {err}"),
1446 })
1447 .await;
1448 }
1449 });
1450 },
1451 Cmd::ComposeInEditor { .. } => {
1452 tracing::warn!("compose_in_editor is unavailable outside the interactive TUI");
1456 },
1457 Cmd::ListMemory => {
1458 let tx = self.msg_tx.clone();
1459 let workdir = self.workdir.clone();
1460 self.detached.spawn(async move {
1461 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1462 let text = match crate::app::memory::load(&workdir, &cfg) {
1463 Some(mem) => mem.index,
1464 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(),
1465 };
1466 let _ = tx.send(Msg::RuntimeText(text)).await;
1467 });
1468 },
1469 Cmd::RememberMemory { text } => {
1470 let tx = self.msg_tx.clone();
1471 let workdir = self.workdir.clone();
1472 self.detached.spawn(async move {
1473 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1474 let name = memory_title_from_text(&text);
1475 let status = match crate::app::memory::write_memory(
1476 &workdir,
1477 mermaid_domain::MemoryScope::ProjectPrivate,
1478 &name,
1479 &text,
1480 &[],
1481 &text,
1482 ) {
1483 Ok(_) => format!("Remembered: {name}"),
1484 Err(e) => format!("Couldn't save memory: {e}"),
1485 };
1486 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1487 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1488 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1489 });
1490 },
1491 Cmd::ForgetMemory { id } => {
1492 let tx = self.msg_tx.clone();
1493 let workdir = self.workdir.clone();
1494 self.detached.spawn(async move {
1495 let cfg = crate::app::load_project_scoped_config(&workdir).memory;
1496 let status = match crate::app::memory::delete_memory(&workdir, &id) {
1497 Ok(Some(_)) => format!("Forgot: {id}"),
1498 Ok(None) => format!("No memory named '{id}'"),
1499 Err(e) => format!("Couldn't forget memory: {e}"),
1500 };
1501 let (loaded, _) = crate::app::memory::refresh(None, &workdir, &cfg);
1502 let _ = tx.send(Msg::MemoryChanged(loaded)).await;
1503 let _ = tx.send(Msg::TransientStatus { text: status }).await;
1504 });
1505 },
1506 Cmd::ConsolidateMemory { model_id } => {
1507 let tx = self.msg_tx.clone();
1508 let workdir = self.workdir.clone();
1509 let providers = self.providers.clone();
1510 self.detached.spawn(async move {
1511 consolidate_memory(tx, providers, workdir, model_id).await;
1512 });
1513 },
1514 Cmd::Query(query) => self.dispatch_query(query),
1515 Cmd::ShowRuntimeProcessLogs { id } => {
1516 let tx = self.msg_tx.clone();
1517 self.detached.spawn_blocking(move || {
1518 let text = crate::runtime_client::RuntimeClient::auto()
1519 .process_log(&id, None)
1520 .map(|log| format!("Process log {}\n\n{}", id, log.content))
1521 .unwrap_or_else(|err| format!("Process log error: {err}"));
1522 let _ = tx.blocking_send(Msg::RuntimeText(text));
1523 });
1524 },
1525 Cmd::StopRuntimeProcess { id } => {
1526 let tx = self.msg_tx.clone();
1527 self.detached.spawn_blocking(move || {
1528 let msg = match crate::runtime_client::RuntimeClient::auto().stop_process(&id) {
1529 Ok(response) => Msg::TransientStatus {
1530 text: format!("Stopped process {} (pid {})", id, response.item.pid),
1531 },
1532 Err(err) => Msg::TransientStatus {
1533 text: format!("Process stop failed: {err}"),
1534 },
1535 };
1536 let _ = tx.blocking_send(msg);
1537 });
1538 },
1539 Cmd::KillBackgroundAgent { agent_id } => {
1540 let spawner = self.tools.as_ref().and_then(|t| t.subagent_spawner());
1544 if let Some(spawner) = spawner {
1545 match agent_id {
1546 Some(id) => {
1547 if let crate::providers::tool::subagent::KillResult::Evicted(
1552 workspace,
1553 ) = spawner.kill_detached(&id)
1554 {
1555 self.detached.spawn(async move {
1559 workspace.discard().await;
1560 });
1561 }
1562 },
1563 None => {
1564 spawner.kill_all_detached();
1565 },
1566 }
1567 }
1568 },
1569 Cmd::RestartRuntimeProcess { id } => {
1570 let tx = self.msg_tx.clone();
1571 self.detached.spawn_blocking(move || {
1572 let msg = match crate::runtime_client::RuntimeClient::auto()
1573 .restart_process(&id)
1574 {
1575 Ok(response) => Msg::TransientStatus {
1576 text: format!("Restarted process {} (pid {})", id, response.item.pid),
1577 },
1578 Err(err) => Msg::TransientStatus {
1579 text: format!("Process restart failed: {err}"),
1580 },
1581 };
1582 let _ = tx.blocking_send(msg);
1583 });
1584 },
1585 Cmd::OpenRuntimeTarget { target } => {
1586 self.detached.spawn_blocking(move || {
1587 let resolved = crate::runtime_client::RuntimeService::open_default()
1588 .and_then(|service| service.resolve_open_target(&target))
1589 .unwrap_or(target);
1590 if let Err(err) = crate::runtime_client::validate_open_target(&resolved) {
1594 tracing::warn!(error = %err, "refusing to open runtime target");
1595 return;
1596 }
1597 mermaid_model::utils::open_file(resolved);
1598 });
1599 },
1600 Cmd::ShowRuntimePorts => {
1601 let tx = self.msg_tx.clone();
1602 self.detached.spawn_blocking(move || {
1603 let text = crate::runtime_client::RuntimeClient::auto()
1604 .ports()
1605 .map(|ports| format!("Listening TCP ports\n\n{}", ports.ports))
1606 .unwrap_or_else(|err| format!("Port inspection failed: {err}"));
1607 let _ = tx.blocking_send(Msg::RuntimeText(text));
1608 });
1609 },
1610 Cmd::DecideRuntimeApproval { id, decision } => {
1611 let tx = self.msg_tx.clone();
1612 self.detached.spawn_blocking(move || {
1613 let result = if decision == "approved" {
1614 crate::runtime_client::RuntimeClient::auto().approve(&id)
1615 } else {
1616 crate::runtime_client::RuntimeClient::auto().deny(&id)
1617 };
1618 let msg = match result {
1619 Ok(result) => Msg::TransientStatus {
1620 text: if result.replayed {
1621 format!("Approval {} {}: {}", id, decision, result.summary)
1622 } else {
1623 format!("Approval {id} {decision}")
1624 },
1625 },
1626 Err(err) => Msg::TransientStatus {
1627 text: format!("Approval update failed: {err}"),
1628 },
1629 };
1630 let _ = tx.blocking_send(msg);
1631 });
1632 },
1633 Cmd::UpdateRuntimeTaskStatus {
1634 id,
1635 status,
1636 final_report,
1637 } => {
1638 let tx = self.msg_tx.clone();
1639 self.detached.spawn_blocking(move || {
1640 let msg = match mermaid_runtime::with_shared_store(|store| {
1641 store
1642 .tasks()
1643 .update_status(&id, status, final_report.as_deref())
1644 }) {
1645 Ok(()) => Msg::TransientStatus {
1646 text: format!("Task {id} -> {status}"),
1647 },
1648 Err(err) => Msg::TransientStatus {
1649 text: format!("Task update failed: {err}"),
1650 },
1651 };
1652 let _ = tx.blocking_send(msg);
1653 });
1654 },
1655 Cmd::CreateRuntimeCheckpoint { paths } => {
1656 let tx = self.msg_tx.clone();
1657 let workdir = self.workdir.clone();
1658 self.detached.spawn_blocking(move || {
1659 let pending_action = Some(serde_json::json!({
1660 "source": "tui",
1661 "command": "checkpoint",
1662 }));
1663 let msg = match mermaid_runtime::create_checkpoint(
1664 &workdir,
1665 &paths,
1666 pending_action,
1667 ) {
1668 Ok(manifest) => Msg::TransientStatus {
1669 text: format!(
1670 "Checkpoint {} created for {} path(s)",
1671 manifest.id,
1672 manifest.files.len()
1673 ),
1674 },
1675 Err(err) => Msg::TransientStatus {
1676 text: format!("Checkpoint failed: {err}"),
1677 },
1678 };
1679 let _ = tx.blocking_send(msg);
1680 });
1681 },
1682 Cmd::RestoreRuntimeCheckpoint { id } => {
1683 let tx = self.msg_tx.clone();
1684 self.detached.spawn_blocking(move || {
1685 let msg = match crate::runtime_client::RuntimeClient::auto()
1686 .restore_checkpoint(&id)
1687 {
1688 Ok(result) => Msg::TransientStatus {
1689 text: format!(
1690 "Restored checkpoint {} ({} file(s)){}",
1691 result.checkpoint.id,
1692 result.checkpoint.files.len(),
1693 if result.checkpoint.pending_action.is_some() {
1694 "; pending action available in checkpoint manifest"
1695 } else {
1696 ""
1697 }
1698 ),
1699 },
1700 Err(err) => Msg::TransientStatus {
1701 text: format!("Restore failed: {err}"),
1702 },
1703 };
1704 let _ = tx.blocking_send(msg);
1705 });
1706 },
1707 Cmd::ShowRuntimeModelInfo { model } => {
1708 let tx = self.msg_tx.clone();
1709 self.detached.spawn_blocking(move || {
1710 let text = runtime_model_info_text(&model);
1711 let _ = tx.blocking_send(Msg::RuntimeText(text));
1712 });
1713 },
1714 Cmd::InitMcpServers(configs) => {
1715 let tx = self.msg_tx.clone();
1716 self.detached
1717 .spawn(async move { dispatch_init_mcp_servers(configs, tx).await });
1718 },
1719 Cmd::StopMcpServer { name } => {
1720 let tx = self.msg_tx.clone();
1721 self.detached.spawn(async move {
1722 if let Some(mgr) = crate::mcp::manager_ref::get() {
1725 mgr.stop_server(&name).await;
1726 }
1727 let _ = tx.send(Msg::McpServerStopped { name }).await;
1728 });
1729 },
1730 Cmd::PullOllamaModel { model } => {
1731 let tx = self.msg_tx.clone();
1732 self.detached.spawn(async move {
1733 dispatch_pull_ollama_model(tx, model).await;
1734 });
1735 },
1736 Cmd::OpenInSystem(path) => {
1737 self.detached.spawn(async move {
1738 let _ = tokio::task::spawn_blocking(move || {
1739 mermaid_model::utils::open_file(&path);
1740 })
1741 .await;
1742 });
1743 },
1744 Cmd::WriteImageToTemp {
1745 path,
1746 bytes,
1747 format: _,
1748 } => {
1749 self.detached.spawn(async move {
1750 if let Err(e) = tokio::fs::write(&path, &bytes).await {
1751 tracing::warn!(path = %path.display(), error = %e, "WriteImageToTemp failed");
1752 }
1753 });
1754 },
1755 Cmd::ReadClipboard => {
1756 let tx = self.msg_tx.clone();
1757 self.detached.spawn(async move {
1758 dispatch_read_clipboard(tx).await;
1759 });
1760 },
1761 Cmd::ProbeVision { model_id, warn } => {
1762 let tx = self.msg_tx.clone();
1763 let providers = self.providers.clone();
1764 self.detached.spawn(async move {
1765 dispatch_probe_vision(model_id, warn, providers, tx).await;
1766 });
1767 },
1768 Cmd::CopyToClipboard(text) => {
1769 let tx = self.msg_tx.clone();
1770 self.detached.spawn(async move {
1771 dispatch_copy_to_clipboard(text, tx).await;
1772 });
1773 },
1774 Cmd::Exit => {
1775 },
1780 Cmd::SetTerminalTitle(title) => {
1781 if !self.terminal_title_enabled {
1782 return;
1783 }
1784 self.detached.spawn_blocking(move || {
1790 use std::io::Write;
1791 let seq = format!("\x1b]2;{title}\x07");
1792 let mut stdout = std::io::stdout();
1793 let _ = stdout.write_all(seq.as_bytes());
1794 let _ = stdout.flush();
1795 });
1796 },
1797 Cmd::AlertUser => {
1798 if !self.terminal_title_enabled {
1799 return;
1800 }
1801 self.detached.spawn_blocking(|| {
1804 use std::io::Write;
1805 let mut stdout = std::io::stdout();
1806 let _ = stdout.write_all(b"\x07");
1807 let _ = stdout.flush();
1808 });
1809 },
1810 }
1811 }
1812
1813 fn queue_persistence(&mut self, job: PersistenceJob) {
1814 let previous = self.persistence_tail.take();
1815 let state = Arc::clone(&self.persistence_state);
1816 let tx = self.msg_tx.clone();
1817 self.persistence_tail = Some(tokio::spawn(async move {
1818 if let Some(previous) = previous
1819 && let Err(error) = previous.await
1820 {
1821 tracing::warn!(error = %error, "previous persistence job panicked");
1822 }
1823
1824 let result = tokio::task::spawn_blocking(move || {
1825 state
1826 .lock()
1827 .unwrap_or_else(|error| error.into_inner())
1828 .process(job)
1829 })
1830 .await;
1831
1832 match result {
1833 Ok((events, outcome)) => {
1834 if outcome.is_ok() || !events.is_empty() {
1838 let _ = tx.send(Msg::SessionSaved).await;
1839 }
1840 for event in events {
1841 fire_compaction_hook(&event).await;
1842 }
1843 if let Err(error) = outcome {
1844 tracing::warn!(
1845 error = %error,
1846 "persistence job failed; compaction barriers remain queued"
1847 );
1848 }
1849 },
1850 Err(error) => tracing::warn!(error = %error, "persistence job panicked"),
1851 }
1852 }));
1853 }
1854
1855 pub async fn shutdown(mut self) {
1859 for (id, scope) in self.scopes.iter() {
1860 tracing::debug!(turn = %id, "shutdown: cancelling scope");
1861 scope.cancel();
1862 }
1863
1864 if let Some(handle) = self.config_watch.take() {
1867 handle.abort();
1868 }
1869
1870 let shutdown_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1872
1873 let owns_global_mcp = self.owns_global_mcp;
1874 let persistence_tail = self.persistence_tail.take();
1875 let persistence_state = Arc::clone(&self.persistence_state);
1876 let drain = async {
1877 if let Some(tail) = persistence_tail
1878 && let Err(error) = tail.await
1879 {
1880 tracing::warn!(error = %error, "shutdown: persistence chain panicked");
1881 }
1882 match tokio::task::spawn_blocking(move || {
1883 let mut state = persistence_state
1884 .lock()
1885 .unwrap_or_else(|error| error.into_inner());
1886 let drained = state.retry_all_blocked();
1887 if let Err(error) = state.flush_checkpoints() {
1891 tracing::warn!(
1892 error = %error,
1893 "shutdown: could not flush a session checkpoint; the log still has everything, so the next resume just folds further"
1894 );
1895 }
1896 drop(state);
1897 drained
1898 })
1899 .await
1900 {
1901 Ok((events, outcome)) => {
1902 for event in events {
1905 fire_compaction_hook(&event).await;
1906 }
1907 if let Err(error) = outcome {
1908 tracing::warn!(
1909 error = %error,
1910 "shutdown: compaction persistence barrier retry failed"
1911 );
1912 }
1913 },
1914 Err(error) => tracing::warn!(
1915 error = %error,
1916 "shutdown: compaction persistence barrier panicked"
1917 ),
1918 }
1919 if owns_global_mcp {
1923 let _ = tokio::time::timeout(
1929 std::time::Duration::from_secs(2),
1930 crate::mcp::manager_ref::wait_ready(),
1931 )
1932 .await;
1933 if let Some(mgr) = crate::mcp::manager_ref::get() {
1940 mgr.shutdown().await;
1941 }
1942 crate::searxng::shutdown().await;
1946 }
1947 for (id, mut scope) in self.scopes.drain() {
1953 if tokio::time::timeout(CANCEL_DRAIN_TIMEOUT, scope.drain())
1954 .await
1955 .is_err()
1956 {
1957 tracing::warn!(
1958 turn = %id,
1959 timeout_ms = CANCEL_DRAIN_TIMEOUT.as_millis(),
1960 "shutdown: scope drain timed out; aborting its remaining tasks"
1961 );
1962 }
1963 }
1964 while let Some(result) = self.detached.join_next().await {
1965 if let Err(e) = result
1966 && !e.is_cancelled()
1967 {
1968 tracing::warn!(error = %e, "shutdown: detached task panic");
1969 }
1970 }
1971 };
1972
1973 let _ = tokio::time::timeout_at(shutdown_deadline, drain).await;
1974 }
1975}
1976
1977fn note_stream_usage(
1985 tasks: &crate::providers::TaskBroker,
1986 usage: &Option<mermaid_model::models::TokenUsage>,
1987) {
1988 if let Some(usage) = usage {
1989 tasks.add_tokens(usage.completion_tokens as u64);
1990 }
1991}
1992
1993mod compaction;
1994mod memory;
1995mod model_call;
1996mod tool_call;
1997
1998use compaction::*;
1999use memory::*;
2000use model_call::*;
2001use tool_call::*;
2002
2003#[cfg(test)]
2004mod tests {
2005 #[test]
2009 fn compaction_row_maps_every_field_it_claims_to() {
2010 use mermaid_domain::{CompactionEvent, CompactionTrigger};
2011 let record = CompactionEvent {
2012 id: "cmp-1".to_string(),
2013 trigger: CompactionTrigger::Manual,
2014 created_at: chrono::Local::now(),
2015 before_tokens: 9_000,
2016 after_tokens: 1_200,
2017 archived_message_count: 40,
2018 preserved_message_count: 6,
2019 preserved_turn_count: 3,
2020 summary_tokens: 450,
2021 duration_secs: 1.5,
2022 focus: None,
2023 archive_path: None,
2024 };
2025 let row = compaction_row(
2026 &record,
2027 std::path::Path::new("/tmp/archive.json"),
2028 Some("task-7".to_string()),
2029 "sess-3".to_string(),
2030 );
2031 assert_eq!(row.id.as_deref(), Some("cmp-1"));
2032 assert_eq!(row.task_id.as_deref(), Some("task-7"));
2033 assert_eq!(row.session_id.as_deref(), Some("sess-3"));
2034 assert_eq!(row.source_token_estimate, Some(9_000));
2035 assert_eq!(row.summary_token_count, Some(450));
2036 assert_eq!(row.preserved_turns, Some(3));
2037 assert!(row.archive_path.is_some_and(|p| p.contains("archive.json")));
2038 }
2039
2040 use super::*;
2041 use mermaid_domain::ToolCallId;
2042 use std::time::Duration;
2043
2044 fn runner() -> (EffectRunner, mpsc::Receiver<Msg>) {
2045 EffectRunner::pair(PathBuf::from("/tmp"))
2046 }
2047
2048 #[test]
2049 fn runtime_tool_payloads_are_redacted_before_serialization() {
2050 let payload = serde_json::json!({
2051 "url": "https://user:hunter2@example.test/page?X-Amz-Signature=opaque-signature#private",
2052 "authorization": "opaque-secret-value",
2053 "model_content": "Fetched page says OPENAI_API_KEY=sk-abcdefghijklmnop1234 and Authorization: Bearer abcdef123456ghijkl",
2054 });
2055 let serialized = redacted_json_string(&payload).expect("serialize redacted payload");
2056 assert!(
2057 !serialized.contains("hunter2"),
2058 "URL password leaked: {serialized}"
2059 );
2060 assert!(
2061 !serialized.contains("opaque-signature"),
2062 "signed URL leaked: {serialized}"
2063 );
2064 assert!(
2065 !serialized.contains("private"),
2066 "URL fragment leaked: {serialized}"
2067 );
2068 assert!(
2069 !serialized.contains("opaque-secret-value"),
2070 "credential-named field leaked: {serialized}"
2071 );
2072 assert!(
2073 !serialized.contains("abcdef123456ghijkl"),
2074 "bearer token leaked: {serialized}"
2075 );
2076 assert!(
2077 !serialized.contains("sk-abcdefghijklmnop1234"),
2078 "secret-shaped fetched content leaked: {serialized}"
2079 );
2080 assert!(serialized.contains("[REDACTED]"));
2081 }
2082
2083 #[test]
2084 fn project_walk_respects_gitignore_sorts_and_marks_dirs() {
2085 let root = std::env::temp_dir().join(format!(
2086 "mermaid-walk-{}-{:?}",
2087 std::process::id(),
2088 std::thread::current().id()
2089 ));
2090 let _ = std::fs::remove_dir_all(&root);
2091 std::fs::create_dir_all(root.join("src")).unwrap();
2092 std::fs::create_dir_all(root.join("target")).unwrap();
2093 std::fs::create_dir_all(root.join(".git")).unwrap();
2094 std::fs::write(root.join(".gitignore"), "target/\n").unwrap();
2095 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2096 std::fs::write(root.join("target/out.bin"), "ignored").unwrap();
2097 std::fs::write(root.join("README.md"), "readme").unwrap();
2098 std::fs::write(root.join(".hidden"), "hidden").unwrap();
2099
2100 let files = walk_project_files(&root);
2101 assert_eq!(
2102 files,
2103 vec![
2104 "README.md".to_string(),
2105 "src/".to_string(),
2106 "src/main.rs".to_string(),
2107 ],
2108 "sorted, dirs slash-marked, target/ ignored, dotfiles hidden"
2109 );
2110 let _ = std::fs::remove_dir_all(&root);
2111 }
2112
2113 #[test]
2114 fn new_child_suppresses_terminal_title() {
2115 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2119 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2120 let tools = Arc::new(ToolRegistry::new());
2121 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2122 assert!(
2123 !child.terminal_title_enabled,
2124 "subagent child runner must suppress terminal-title escapes"
2125 );
2126 }
2127
2128 #[test]
2129 fn new_child_does_not_own_global_mcp_shutdown() {
2130 let (tx, _rx) = mpsc::channel::<Msg>(MSG_CHANNEL_CAPACITY);
2135 let providers = Arc::new(ProviderFactory::new(mermaid_domain::Config::default()));
2136 let tools = Arc::new(ToolRegistry::new());
2137 let child = EffectRunner::new_child(tx, PathBuf::from("/tmp"), providers, tools);
2138 assert!(
2139 !child.owns_global_mcp,
2140 "child runner must not reap the shared global MCP manager"
2141 );
2142 let (top, _rx2) = EffectRunner::pair(PathBuf::from("/tmp"));
2143 assert!(
2144 top.owns_global_mcp,
2145 "top-level runner still owns the global MCP reap"
2146 );
2147 }
2148
2149 #[test]
2150 fn parse_prune_plan_extracts_json_amid_prose() {
2151 let plan = parse_prune_plan(
2152 "Sure, here's the plan:\n```json\n{\"prune\": [\"a\", \"b\"], \"reason\": \"dupes\"}\n```\nDone.",
2153 )
2154 .expect("should parse");
2155 assert_eq!(plan.prune, vec!["a".to_string(), "b".to_string()]);
2156 assert_eq!(plan.reason, "dupes");
2157 }
2158
2159 #[test]
2160 fn parse_prune_plan_handles_empty_and_garbage() {
2161 let empty = parse_prune_plan("{\"prune\": [], \"reason\": \"all distinct\"}")
2162 .expect("empty plan parses");
2163 assert!(empty.prune.is_empty());
2164 assert!(parse_prune_plan("no json here").is_none());
2165 }
2166
2167 #[test]
2168 fn memory_title_from_text_is_short_and_nonempty() {
2169 assert_eq!(
2170 memory_title_from_text("prefer ripgrep over grep"),
2171 "prefer ripgrep over grep"
2172 );
2173 assert_eq!(memory_title_from_text(" "), "memory");
2174 let long = memory_title_from_text("one two three four five six seven eight nine ten");
2175 assert!(long.split_whitespace().count() <= 8);
2176 }
2177
2178 #[tokio::test]
2179 async fn dispatch_exit_is_noop_on_runner_state() {
2180 let (mut r, _rx) = runner();
2181 r.dispatch(Cmd::Exit);
2182 assert_eq!(r.scope_count(), 0);
2183 }
2184
2185 #[tokio::test]
2186 async fn dispatch_save_emits_session_saved() {
2187 let (mut r, mut rx) = runner();
2188 r.dispatch(Cmd::SaveConversation {
2189 snapshot: mermaid_domain::ConversationHistory::new(
2190 "/p".to_string(),
2191 "m".to_string(),
2192 chrono::Local::now(),
2193 ),
2194 events: Vec::new(),
2195 });
2196 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2197 .await
2198 .expect("sender emits")
2199 .expect("channel alive");
2200 assert!(matches!(msg, Msg::SessionSaved));
2201 }
2202
2203 #[cfg(unix)]
2204 #[tokio::test]
2205 async fn init_mcp_servers_emits_incremental_errored_msgs() {
2206 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
2210 let mut configs = std::collections::HashMap::new();
2211 for name in ["one", "two"] {
2212 configs.insert(
2213 name.to_string(),
2214 mermaid_domain::McpServerConfig {
2215 command: "/nonexistent/mermaid-test-mcp-binary".to_string(),
2216 ..Default::default()
2217 },
2218 );
2219 }
2220 dispatch_init_mcp_servers(configs, tx).await;
2221 let mut errored = Vec::new();
2222 while let Ok(msg) = rx.try_recv() {
2223 match msg {
2224 Msg::McpServerErrored { name, .. } => errored.push(name),
2225 other => panic!("unexpected msg: {other:?}"),
2226 }
2227 }
2228 errored.sort();
2229 assert_eq!(errored, vec!["one".to_string(), "two".to_string()]);
2230 assert!(crate::mcp::manager_ref::is_ready());
2231 }
2232
2233 #[tokio::test]
2234 async fn cancel_scope_emits_turn_cancelled_after_bounded_timeout() {
2235 let (mut r, mut rx) = runner();
2236 let turn = TurnId(77);
2237 {
2238 let scope = r.scope_mut(turn);
2239 scope.spawn(async {
2240 std::future::pending::<()>().await;
2241 });
2242 }
2243 assert_eq!(r.scope_count(), 1);
2244
2245 let start = std::time::Instant::now();
2246 r.dispatch(Cmd::CancelScope(turn));
2247 assert_eq!(r.scope_count(), 0);
2248 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2249 .await
2250 .expect("bounded cancel should emit terminal message")
2251 .expect("channel alive");
2252 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2253 assert!(
2254 start.elapsed() < Duration::from_millis(500),
2255 "cancel terminal message took {:?}",
2256 start.elapsed()
2257 );
2258 }
2259
2260 #[tokio::test]
2261 async fn cancel_scope_emits_turn_cancelled_even_after_reaping() {
2262 let (mut r, mut rx) = runner();
2268 let turn = TurnId(88);
2269 {
2270 let scope = r.scope_mut(turn);
2271 scope.spawn(async {}); }
2273 assert_eq!(r.scope_count(), 1);
2274
2275 tokio::time::sleep(Duration::from_millis(20)).await;
2277 r.dispatch(Cmd::Exit);
2278 assert_eq!(r.scope_count(), 0, "completed scope should be reaped");
2279
2280 r.dispatch(Cmd::CancelScope(turn));
2283 let msg = tokio::time::timeout(Duration::from_millis(500), rx.recv())
2284 .await
2285 .expect("cancel on a reaped scope must still emit a terminal message")
2286 .expect("channel alive");
2287 assert!(matches!(msg, Msg::TurnCancelled(t) if t == turn));
2288 }
2289
2290 #[tokio::test]
2291 async fn dispatch_call_model_creates_scope() {
2292 let (mut r, _rx) = runner();
2293 let turn = TurnId(7);
2294 let request = mermaid_domain::ChatRequest {
2295 model_id: "test/m".to_string(),
2296 messages: vec![],
2297 system_prompt: String::new(),
2298 instructions: None,
2299 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2300 temperature: 0.7,
2301 max_tokens: 4096,
2302 tools: vec![],
2303
2304 ollama_num_ctx: None,
2305 ollama_allow_ram_offload: None,
2306 resolved_context_window: None,
2307 resolved_max_output: None,
2308 output_schema: None,
2309 suppress_auto_compact: false,
2310 requested_compaction: None,
2311 native_compaction: None,
2312 native_tools: mermaid_model::models::NativeTools::default(),
2313 };
2314 r.dispatch(Cmd::CallModel { turn, request });
2315 assert_eq!(r.scope_count(), 1);
2316 }
2317
2318 #[tokio::test]
2322 async fn empty_scopes_are_reaped_on_next_dispatch() {
2323 let (mut r, mut rx) = runner();
2324 let turn = TurnId(42);
2325 let request = mermaid_domain::ChatRequest {
2326 model_id: "test/m".to_string(),
2327 messages: vec![],
2328 system_prompt: String::new(),
2329 instructions: None,
2330 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2331 temperature: 0.7,
2332 max_tokens: 4096,
2333 tools: vec![],
2334
2335 ollama_num_ctx: None,
2336 ollama_allow_ram_offload: None,
2337 resolved_context_window: None,
2338 resolved_max_output: None,
2339 output_schema: None,
2340 suppress_auto_compact: false,
2341 requested_compaction: None,
2342 native_compaction: None,
2343 native_tools: mermaid_model::models::NativeTools::default(),
2344 };
2345 r.dispatch(Cmd::CallModel { turn, request });
2346 assert_eq!(r.scope_count(), 1);
2347
2348 let msg = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2353 .await
2354 .expect("upstream error arrived")
2355 .expect("channel alive");
2356 assert!(matches!(msg, Msg::UpstreamError { .. }));
2357
2358 tokio::task::yield_now().await;
2360
2361 r.dispatch(Cmd::SetTerminalTitle("x".to_string()));
2363 assert_eq!(
2364 r.scope_count(),
2365 0,
2366 "completed scope must be reaped on next dispatch"
2367 );
2368 }
2369
2370 #[tokio::test]
2371 async fn dispatch_execute_tool_under_turn_emits_tool_started() {
2372 let (mut r, mut rx) = runner();
2373 let turn = TurnId(7);
2374 let call_id = ToolCallId(1);
2375 let source = mermaid_model::models::tool_call::ToolCall {
2376 id: Some("c1".to_string()),
2377 function: mermaid_model::models::tool_call::FunctionCall {
2378 name: "read_file".to_string(),
2379 arguments: serde_json::json!({"path": "x"}),
2380 },
2381 };
2382 r.dispatch(Cmd::ExecuteTool {
2383 turn,
2384 call_id,
2385 source,
2386 dispatch: mermaid_domain::ToolDispatch {
2387 model_id: "ollama/test".to_string(),
2388 safety_mode: mermaid_runtime::SafetyMode::Ask,
2389 goal: mermaid_domain::UserGoal::default(),
2390 reasoning: mermaid_model::models::ReasoningLevel::default(),
2391 session_id: "sess-test".to_string(),
2392 message_index: 0,
2393 scratchpad: None,
2394 },
2395 });
2396 let first = tokio::time::timeout(Duration::from_millis(200), rx.recv())
2397 .await
2398 .expect("some msg")
2399 .expect("channel alive");
2400 assert!(matches!(
2401 first,
2402 Msg::ToolStarted {
2403 turn: t,
2404 call_id: c,
2405 } if t == turn && c == call_id
2406 ));
2407 }
2408
2409 #[tokio::test]
2410 async fn cancel_scope_before_execute_tool_drops_pending_work() {
2411 let (mut r, _rx) = runner();
2412 let turn = TurnId(9);
2413 r.dispatch(Cmd::CallModel {
2414 turn,
2415 request: mermaid_domain::ChatRequest {
2416 model_id: "m".to_string(),
2417 messages: vec![],
2418 system_prompt: String::new(),
2419 instructions: None,
2420 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2421 temperature: 0.7,
2422 max_tokens: 4096,
2423 tools: vec![],
2424
2425 ollama_num_ctx: None,
2426 ollama_allow_ram_offload: None,
2427 resolved_context_window: None,
2428 resolved_max_output: None,
2429 output_schema: None,
2430 suppress_auto_compact: false,
2431 requested_compaction: None,
2432 native_compaction: None,
2433 native_tools: mermaid_model::models::NativeTools::default(),
2434 },
2435 });
2436 assert_eq!(r.scope_count(), 1);
2437
2438 r.dispatch(Cmd::CancelScope(turn));
2439 assert_eq!(r.scope_count(), 0);
2440 }
2441
2442 #[tokio::test]
2443 async fn tombstoned_turn_is_not_resurrected_by_late_scoped_cmd() {
2444 let (mut r, _rx) = runner();
2450 let req = || mermaid_domain::ChatRequest {
2451 model_id: "test/m".to_string(),
2452 messages: vec![],
2453 system_prompt: String::new(),
2454 instructions: None,
2455 reasoning: mermaid_model::models::ReasoningLevel::Medium,
2456 temperature: 0.7,
2457 max_tokens: 4096,
2458 tools: vec![],
2459 ollama_num_ctx: None,
2460 ollama_allow_ram_offload: None,
2461 resolved_context_window: None,
2462 resolved_max_output: None,
2463 output_schema: None,
2464 suppress_auto_compact: false,
2465 requested_compaction: None,
2466 native_compaction: None,
2467 native_tools: mermaid_model::models::NativeTools::default(),
2468 };
2469 let turn = TurnId(123);
2470
2471 r.dispatch(Cmd::CallModel {
2472 turn,
2473 request: req(),
2474 });
2475 assert_eq!(r.scope_count(), 1);
2476
2477 r.dispatch(Cmd::CancelScope(turn));
2479 assert_eq!(r.scope_count(), 0);
2480
2481 r.dispatch(Cmd::CallModel {
2483 turn,
2484 request: req(),
2485 });
2486 assert_eq!(
2487 r.scope_count(),
2488 0,
2489 "a cancelled turn must not be resurrected by a late scoped Cmd"
2490 );
2491
2492 r.dispatch(Cmd::CallModel {
2494 turn: TurnId(124),
2495 request: req(),
2496 });
2497 assert_eq!(
2498 r.scope_count(),
2499 1,
2500 "a fresh turn must still create its scope normally"
2501 );
2502 }
2503
2504 #[tokio::test]
2505 async fn shutdown_drains_pending_saves() {
2506 let (mut r, _rx) = runner();
2507 for _ in 0..5 {
2508 r.dispatch(Cmd::SaveConversation {
2509 snapshot: mermaid_domain::ConversationHistory::new(
2510 "/p".to_string(),
2511 "m".to_string(),
2512 chrono::Local::now(),
2513 ),
2514 events: Vec::new(),
2515 });
2516 }
2517 let start = std::time::Instant::now();
2519 r.shutdown().await;
2520 assert!(start.elapsed() < Duration::from_secs(2));
2521 }
2522
2523 fn persistence_fixture(
2524 root: &std::path::Path,
2525 record_id: &str,
2526 ) -> (mermaid_domain::ConversationHistory, PendingCompactionSave) {
2527 let now = chrono::Local::now();
2528 let mut full = mermaid_domain::ConversationHistory::new(
2529 root.display().to_string(),
2530 "test/model".to_string(),
2531 now,
2532 );
2533 full.add_messages(
2534 &[mermaid_model::models::ChatMessage::user("raw history")],
2535 now,
2536 );
2537 let mut compacted = full.clone();
2538 compacted.replace_messages(
2539 vec![mermaid_model::models::ChatMessage::user(
2540 "compacted checkpoint",
2541 )],
2542 now,
2543 );
2544 let record = mermaid_domain::CompactionEvent {
2545 id: record_id.to_string(),
2546 trigger: mermaid_domain::CompactionTrigger::Manual,
2547 created_at: now,
2548 before_tokens: 100,
2549 after_tokens: 20,
2550 archived_message_count: 1,
2551 preserved_message_count: 1,
2552 preserved_turn_count: 1,
2553 summary_tokens: 10,
2554 duration_secs: 0.1,
2555 focus: None,
2556 archive_path: None,
2557 };
2558 (
2559 full,
2560 PendingCompactionSave {
2561 record,
2562 conversation: compacted,
2563 events: vec![mermaid_domain::SessionEvent::Input {
2566 text: "compaction boundary".to_string(),
2567 }],
2568 events_appended: false,
2569 task_id: None,
2570 },
2571 )
2572 }
2573
2574 fn block_event_log(root: &std::path::Path, id: &str) {
2579 let dir = root.join(".mermaid").join("conversations");
2580 std::fs::create_dir_all(&dir).expect("conversations dir");
2581 std::fs::create_dir_all(dir.join(format!("{id}.jsonl"))).expect("plant a blocker");
2582 }
2583
2584 fn one_message_save(
2586 conversation: &mut mermaid_domain::ConversationHistory,
2587 text: &str,
2588 ) -> PersistenceJob {
2589 let message = mermaid_model::models::ChatMessage::user(text);
2590 conversation.add_messages(std::slice::from_ref(&message), chrono::Local::now());
2591 PersistenceJob::Conversation {
2592 snapshot: Box::new(conversation.clone()),
2593 events: vec![mermaid_domain::SessionEvent::Message { message }],
2594 }
2595 }
2596
2597 #[test]
2598 fn the_checkpoint_stops_being_written_on_every_save() {
2599 let root = std::env::temp_dir().join(format!(
2603 "mermaid-throttle-{}-{:?}",
2604 std::process::id(),
2605 std::thread::current().id()
2606 ));
2607 let _ = std::fs::remove_dir_all(&root);
2608 let manager = crate::session::ConversationManager::new(&root).unwrap();
2609 let mut conversation = mermaid_domain::ConversationHistory::new(
2610 root.display().to_string(),
2611 "test/model".to_string(),
2612 chrono::Local::now(),
2613 );
2614 let mut state = PersistenceState::new(root.clone());
2615
2616 state
2619 .process(one_message_save(&mut conversation, "first"))
2620 .1
2621 .unwrap();
2622 let checkpoint = manager
2623 .conversations_dir()
2624 .join(format!("{}.json", conversation.id));
2625 assert!(
2626 !checkpoint.exists(),
2627 "a single save must not rewrite the transcript"
2628 );
2629
2630 let resumed = manager.load_conversation(&conversation.id).unwrap();
2632 assert_eq!(resumed.messages().len(), 1);
2633 assert_eq!(resumed.messages()[0].content, "first");
2634
2635 for i in 0..CHECKPOINT_EVERY_EVENTS {
2637 state
2638 .process(one_message_save(&mut conversation, &format!("m{i}")))
2639 .1
2640 .unwrap();
2641 }
2642 assert!(
2643 checkpoint.exists(),
2644 "crossing {CHECKPOINT_EVERY_EVENTS} events must materialize a checkpoint"
2645 );
2646 let resumed = manager.load_conversation(&conversation.id).unwrap();
2647 assert_eq!(resumed.messages().len(), CHECKPOINT_EVERY_EVENTS + 1);
2648 let _ = std::fs::remove_dir_all(root);
2649 }
2650
2651 #[test]
2652 fn shutdown_flushes_the_checkpoint_it_was_holding() {
2653 let root = std::env::temp_dir().join(format!(
2654 "mermaid-flush-{}-{:?}",
2655 std::process::id(),
2656 std::thread::current().id()
2657 ));
2658 let _ = std::fs::remove_dir_all(&root);
2659 let manager = crate::session::ConversationManager::new(&root).unwrap();
2660 let mut conversation = mermaid_domain::ConversationHistory::new(
2661 root.display().to_string(),
2662 "test/model".to_string(),
2663 chrono::Local::now(),
2664 );
2665 let mut state = PersistenceState::new(root.clone());
2666 state
2667 .process(one_message_save(&mut conversation, "only message"))
2668 .1
2669 .unwrap();
2670
2671 let checkpoint = manager
2672 .conversations_dir()
2673 .join(format!("{}.json", conversation.id));
2674 assert!(!checkpoint.exists());
2675 state.flush_checkpoints().unwrap();
2676 assert!(
2677 checkpoint.exists(),
2678 "a clean exit must leave a current checkpoint"
2679 );
2680 let raw = std::fs::read_to_string(&checkpoint).unwrap();
2682 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
2683 assert!(
2684 value.get("checkpoint_seq").is_some(),
2685 "a flushed checkpoint must be placeable in its log: {raw}"
2686 );
2687 let _ = std::fs::remove_dir_all(root);
2688 }
2689
2690 #[test]
2691 fn a_failed_append_keeps_its_events_for_the_next_save() {
2692 let root = std::env::temp_dir().join(format!(
2695 "mermaid-unappended-{}-{:?}",
2696 std::process::id(),
2697 std::thread::current().id()
2698 ));
2699 let _ = std::fs::remove_dir_all(&root);
2700 let manager = crate::session::ConversationManager::new(&root).unwrap();
2701 let mut conversation = mermaid_domain::ConversationHistory::new(
2702 root.display().to_string(),
2703 "test/model".to_string(),
2704 chrono::Local::now(),
2705 );
2706 let mut state = PersistenceState::new(root.clone());
2707
2708 std::fs::create_dir_all(
2710 manager
2711 .conversations_dir()
2712 .join(format!("{}.jsonl", conversation.id)),
2713 )
2714 .expect("plant a blocker");
2715 let job = one_message_save(&mut conversation, "must survive");
2716 assert!(state.process(job).1.is_err(), "the append must fail");
2717 assert_eq!(
2718 state.unappended.get(&conversation.id).map(Vec::len),
2719 Some(1),
2720 "the batch must be held, not dropped"
2721 );
2722
2723 std::fs::remove_dir_all(
2725 manager
2726 .conversations_dir()
2727 .join(format!("{}.jsonl", conversation.id)),
2728 )
2729 .expect("unblock");
2730 state
2731 .process(one_message_save(&mut conversation, "and this one"))
2732 .1
2733 .unwrap();
2734 assert!(state.unappended.is_empty(), "the hold must clear");
2735 state.flush_checkpoints().unwrap();
2736
2737 let resumed = manager.load_conversation(&conversation.id).unwrap();
2738 let texts: Vec<&str> = resumed
2739 .messages()
2740 .iter()
2741 .map(|m| m.content.as_str())
2742 .collect();
2743 assert!(
2744 texts.contains(&"must survive"),
2745 "the event held over a failed append must reach the log: {texts:?}"
2746 );
2747 assert!(texts.contains(&"and this one"), "{texts:?}");
2748 let _ = std::fs::remove_dir_all(root);
2749 }
2750
2751 #[test]
2752 fn persistence_orders_compaction_before_newer_conversation_save() {
2753 let root = std::env::temp_dir().join(format!(
2754 "mermaid-persistence-order-{}-{:?}",
2755 std::process::id(),
2756 std::thread::current().id()
2757 ));
2758 let _ = std::fs::remove_dir_all(&root);
2759 let (full, compaction) = persistence_fixture(&root, "compact_ordered");
2760 let manager = crate::session::ConversationManager::new(&root).unwrap();
2761 manager.save_conversation(&full).unwrap();
2762
2763 let mut state = PersistenceState::new(root.clone());
2764 let (events, outcome) =
2765 state.process(PersistenceJob::Compaction(Box::new(compaction.clone())));
2766 outcome.unwrap();
2767 assert_eq!(events.len(), 1);
2768 let mut newer = compaction.conversation;
2769 let reply = mermaid_model::models::ChatMessage::assistant("new assistant reply");
2770 newer.add_messages(std::slice::from_ref(&reply), chrono::Local::now());
2771 let (_, outcome) = state.process(PersistenceJob::Conversation {
2776 snapshot: Box::new(newer),
2777 events: vec![mermaid_domain::SessionEvent::Message { message: reply }],
2778 });
2779 outcome.unwrap();
2780
2781 let loaded = crate::session::ConversationManager::new(&root)
2782 .unwrap()
2783 .load_conversation(&full.id)
2784 .unwrap();
2785 assert!(
2786 loaded
2787 .messages()
2788 .iter()
2789 .any(|message| message.content == "new assistant reply")
2790 );
2791 let _ = std::fs::remove_dir_all(root);
2792 }
2793
2794 #[test]
2795 fn failed_event_append_blocks_later_stripped_conversation_save() {
2796 let root = std::env::temp_dir().join(format!(
2797 "mermaid-persistence-barrier-{}-{:?}",
2798 std::process::id(),
2799 std::thread::current().id()
2800 ));
2801 let _ = std::fs::remove_dir_all(&root);
2802 let (full, compaction) = persistence_fixture(&root, "compact_blocked");
2803 let manager = crate::session::ConversationManager::new(&root).unwrap();
2804 manager.save_conversation(&full).unwrap();
2805 block_event_log(&root, &full.id);
2806
2807 let mut state = PersistenceState::new(root.clone());
2808 assert!(
2809 state
2810 .process(PersistenceJob::Compaction(Box::new(compaction.clone())))
2811 .1
2812 .is_err()
2813 );
2814 assert!(
2815 state
2816 .process(PersistenceJob::Conversation {
2817 snapshot: Box::new(compaction.conversation),
2818 events: Vec::new(),
2819 })
2820 .1
2821 .is_err()
2822 );
2823 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2824
2825 let loaded = crate::session::ConversationManager::new(&root)
2826 .unwrap()
2827 .load_conversation(&full.id)
2828 .unwrap();
2829 assert_eq!(loaded.messages()[0].content, "raw history");
2830 let _ = std::fs::remove_dir_all(root);
2831 }
2832
2833 #[test]
2834 fn blocked_barrier_queues_a_new_compaction_instead_of_dropping_it() {
2835 let root = std::env::temp_dir().join(format!(
2836 "mermaid-persistence-queue-{}-{:?}",
2837 std::process::id(),
2838 std::thread::current().id()
2839 ));
2840 let _ = std::fs::remove_dir_all(&root);
2841 let (full, first) = persistence_fixture(&root, "compact_first");
2842 let mut second = first.clone();
2843 second.record.id = "compact_second".to_string();
2844 block_event_log(&root, &full.id);
2845
2846 let mut state = PersistenceState::new(root.clone());
2847 assert!(
2848 state
2849 .process(PersistenceJob::Compaction(Box::new(first)))
2850 .1
2851 .is_err()
2852 );
2853 assert!(
2856 state
2857 .process(PersistenceJob::Compaction(Box::new(second)))
2858 .1
2859 .is_err()
2860 );
2861 let queued = state.blocked.get(&full.id).expect("barrier queue");
2862 assert_eq!(queued.len(), 2);
2863 assert_eq!(queued[0].record.id, "compact_first");
2864 assert_eq!(queued[1].record.id, "compact_second");
2865 let _ = std::fs::remove_dir_all(root);
2866 }
2867
2868 #[test]
2869 fn retry_all_blocked_attempts_every_conversation() {
2870 let root = std::env::temp_dir().join(format!(
2871 "mermaid-persistence-drain-{}-{:?}",
2872 std::process::id(),
2873 std::thread::current().id()
2874 ));
2875 let _ = std::fs::remove_dir_all(&root);
2876 let (bad_full, bad) = persistence_fixture(&root, "compact_bad");
2877 let (mut good_full, mut good) = persistence_fixture(&root, "compact_good");
2878 block_event_log(&root, &bad_full.id);
2879 good_full.id = "20990101_000000_001".to_string();
2883 good.conversation.id = good_full.id.clone();
2884
2885 let mut state = PersistenceState::new(root.clone());
2886 state
2887 .blocked
2888 .entry(bad_full.id.clone())
2889 .or_default()
2890 .push_back(bad);
2891 state
2892 .blocked
2893 .entry(good_full.id.clone())
2894 .or_default()
2895 .push_back(good);
2896
2897 let (events, outcome) = state.retry_all_blocked();
2901 assert!(outcome.is_err());
2902 assert_eq!(events.len(), 1);
2903 assert_eq!(events[0].id, "compact_good");
2904 assert!(!state.blocked.contains_key(&good_full.id));
2905 assert_eq!(state.blocked.get(&bad_full.id).map(VecDeque::len), Some(1));
2906 let loaded = crate::session::ConversationManager::new(&root)
2907 .unwrap()
2908 .load_conversation(&good_full.id)
2909 .unwrap();
2910 assert_eq!(loaded.messages()[0].content, "compacted checkpoint");
2911 let _ = std::fs::remove_dir_all(root);
2912 }
2913
2914 #[test]
2915 fn partially_drained_barrier_reports_its_persisted_events() {
2916 let root = std::env::temp_dir().join(format!(
2917 "mermaid-persistence-partial-{}-{:?}",
2918 std::process::id(),
2919 std::thread::current().id()
2920 ));
2921 let _ = std::fs::remove_dir_all(&root);
2922 let (full, good) = persistence_fixture(&root, "compact_good");
2923 let mut bad = good.clone();
2924 bad.record.id = "compact_bad".to_string();
2925 bad.conversation.id = "../invalid".to_string();
2928
2929 let mut state = PersistenceState::new(root.clone());
2930 let queue = state.blocked.entry(full.id.clone()).or_default();
2931 queue.push_back(good);
2932 queue.push_back(bad);
2933
2934 let (events, outcome) = state.retry_blocked(&full.id);
2938 assert!(outcome.is_err());
2939 assert_eq!(events.len(), 1);
2940 assert_eq!(events[0].id, "compact_good");
2941 assert_eq!(state.blocked.get(&full.id).map(VecDeque::len), Some(1));
2942 let _ = std::fs::remove_dir_all(root);
2943 }
2944}