1use std::sync::Arc;
15use std::time::Duration;
16
17use zeph_durable::{
18 DurableBackendEnum, DurableRetentionService, ExecutionId, JournalWriterHandle, LocalBackend,
19 PayloadCipher,
20};
21
22use crate::agent::Agent;
23use crate::channel::Channel;
24
25const RETENTION_TASK_NAME: &str = "durable.retention_sweep";
33
34pub struct DurableKeyMaterial {
78 pub cipher: Option<Arc<dyn PayloadCipher>>,
80 pub hmac_key: Option<[u8; 32]>,
82 pub hwm_key: Option<(u32, [u8; 32])>,
84 pub previous_hmac_key: Option<[u8; 32]>,
86 pub previous_hwm_key: Option<(u32, [u8; 32])>,
88 pub integrity_sealed: bool,
90 pub integrity_grandfather: std::collections::HashSet<ExecutionId>,
92}
93
94pub(crate) async fn open_durable_backend(
103 task_supervisor: &zeph_common::TaskSupervisor,
104 writer_task_name: &'static str,
105 cfg: &zeph_config::DurableConfig,
106 db_url: &str,
107 key_material: DurableKeyMaterial,
108) -> Option<(
109 Arc<DurableBackendEnum>,
110 JournalWriterHandle,
111 zeph_common::task_supervisor::BlockingHandle<()>,
112)> {
113 let DurableKeyMaterial {
114 cipher,
115 hmac_key,
116 hwm_key,
117 previous_hmac_key,
118 previous_hwm_key,
119 integrity_sealed,
120 integrity_grandfather,
121 } = key_material;
122
123 let local = match LocalBackend::open(db_url, cfg.max_payload_bytes).await {
124 Ok(b) => b,
125 Err(e) => {
126 tracing::warn!(error = %e, db_url, "durable: failed to open backend; skipping");
127 return None;
128 }
129 };
130 if let Err(e) = local.init().await {
131 tracing::warn!(error = %e, "durable: failed to init schema; skipping");
132 return None;
133 }
134 let local = if let Some(c) = cipher {
135 local.with_cipher(c)
136 } else {
137 local
138 };
139 let local = if let Some(k) = hmac_key {
140 local.with_hmac_key(k)
141 } else {
142 local
143 };
144 let local = if let Some((epoch, k)) = hwm_key {
145 local.with_hwm_key(epoch, k)
146 } else {
147 local
148 };
149 let local = if let Some(k) = previous_hmac_key {
150 local.with_previous_hmac_key(k)
151 } else {
152 local
153 };
154 let local = if let Some((epoch, k)) = previous_hwm_key {
155 local.with_previous_hwm_key(epoch, k)
156 } else {
157 local
158 };
159 let local = local
160 .with_integrity_sealed(integrity_sealed)
161 .with_grandfather(integrity_grandfather);
162 let local = Arc::new(local);
163 let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
164 let (writer_actor, handle) = zeph_durable::JournalWriter::new(local, cfg);
165 let task_handle =
166 task_supervisor.spawn_oneshot(Arc::from(writer_task_name), move || async move {
167 writer_actor.run().await;
168 });
169
170 let retention_backend = Arc::clone(&backend);
171 let retention_policy = cfg.retention.clone();
172 task_supervisor.spawn(zeph_common::TaskDescriptor {
173 name: RETENTION_TASK_NAME,
174 restart: zeph_common::RestartPolicy::Restart {
175 max: 5,
176 base_delay: Duration::from_secs(5),
177 },
178 factory: move || {
179 DurableRetentionService::new(Arc::clone(&retention_backend), retention_policy.clone())
180 .run()
181 },
182 });
183
184 Some((backend, handle, task_handle))
185}
186
187impl<C: Channel> Agent<C> {
188 #[allow(clippy::too_many_lines)]
206 pub(crate) async fn ensure_session_durable_ctx(&mut self) {
207 if self.services.session.durable_ctx.is_some()
208 || self.services.session.durable_ctx_init_attempted
209 {
210 return;
211 }
212 self.services.session.durable_ctx_init_attempted = true;
213
214 let Some(cfg) = self.services.session.durable_agent_turns_config.clone() else {
215 return;
216 };
217 let Some(db_url) = self.services.session.durable_agent_turns_db_url.clone() else {
218 return;
219 };
220 let sqlite_path = self
221 .services
222 .session
223 .durable_agent_turns_sqlite_path
224 .clone()
225 .unwrap_or_default();
226 let Some(conversation_id) = self.services.memory.persistence.conversation_id else {
227 tracing::warn!(
228 "durable agent_turns: no conversation_id at bootstrap; degrading to non-durable"
229 );
230 return;
231 };
232 let key_material = DurableKeyMaterial {
233 cipher: self.services.session.durable_agent_turns_cipher.clone(),
234 hmac_key: self.services.session.durable_agent_turns_hmac_key,
235 hwm_key: self.services.session.durable_agent_turns_hwm_key,
236 previous_hmac_key: self.services.session.durable_agent_turns_previous_hmac_key,
237 previous_hwm_key: self.services.session.durable_agent_turns_previous_hwm_key,
238 integrity_sealed: self.services.session.durable_agent_turns_integrity_sealed,
239 integrity_grandfather: self
240 .services
241 .session
242 .durable_agent_turns_integrity_grandfather
243 .clone(),
244 };
245
246 tracing::debug!("durable agent_turns: opening backend start");
247 let backend_result = open_durable_backend(
248 &self.runtime.lifecycle.task_supervisor,
249 "agent.durable.turn_journal_writer",
250 &cfg,
251 &db_url,
252 key_material,
253 )
254 .await;
255 tracing::debug!("durable agent_turns: opening backend done");
256 let Some((backend, writer, task_handle)) = backend_result else {
257 tracing::warn!(
258 "durable agent_turns: backend construction failed; degrading to non-durable"
259 );
260 return;
261 };
262
263 let zeph_durable::DurableBackendEnum::Local(local_backend) = &*backend else {
264 tracing::warn!(
265 "durable agent_turns: only LocalBackend is supported; degrading to non-durable"
266 );
267 return;
268 };
269
270 let mut exec_payload = conversation_id.0.to_le_bytes().to_vec();
277 exec_payload.extend_from_slice(sqlite_path.as_bytes());
278 let exec_id = zeph_durable::ExecutionId::derive(b"zeph.agent_turn.v1", &exec_payload);
279 tracing::debug!("durable agent_turns: open_execution start");
280 let open_execution_result = local_backend
286 .open_execution_exclusive(exec_id, zeph_durable::ExecutionKind::AgentTurn)
287 .await;
288 tracing::debug!("durable agent_turns: open_execution done");
289 let (is_resume, execution_lock) = match open_execution_result {
290 Ok(r) => r,
291 Err(zeph_durable::DurableError::ExecutionLocked {
292 execution_id,
293 holder_pid,
294 }) => {
295 tracing::warn!(
296 %execution_id,
297 holder_pid,
298 "durable agent_turns: execution already open in another process; \
299 degrading to non-durable"
300 );
301 return;
302 }
303 Err(e) => {
304 tracing::warn!(
305 error = %e,
306 "durable agent_turns: open_execution failed; degrading to non-durable"
307 );
308 return;
309 }
310 };
311
312 let ctx = zeph_durable::DurableContext::new(
313 exec_id,
314 zeph_durable::ExecutionKind::AgentTurn,
315 is_resume,
316 backend,
317 writer.clone(),
318 &cfg,
319 );
320
321 tracing::info!(
322 execution_id = %exec_id.as_uuid(),
323 is_resume,
324 "durable agent_turns: DurableContext attached to session"
325 );
326 self.services.session.durable_ctx = Some(Arc::new(ctx));
327 self.services.session.durable_writer = Some(writer);
328 self.services.session.durable_writer_task = Some(task_handle);
329 self.services.session.durable_execution_lock = execution_lock;
330 }
331
332 pub(in crate::agent) async fn reset_durable_ctx_for_conversation_switch(&mut self) {
348 let flush_deadline = std::time::Duration::from_secs(2);
349 if let Some(ref writer) = self.services.session.durable_writer {
350 match tokio::time::timeout(flush_deadline, writer.flush()).await {
351 Ok(Ok(())) => {}
352 Ok(Err(e)) => {
353 tracing::warn!(
354 error = %e,
355 "durable agent_turns writer: flush on conversation switch failed"
356 );
357 }
358 Err(_) => tracing::warn!(
359 "durable agent_turns writer: flush timed out on conversation switch"
360 ),
361 }
362 }
363 if let Some(ref ctx) = self.services.session.durable_ctx {
364 match tokio::time::timeout(
365 flush_deadline,
366 ctx.finalize(zeph_durable::ExecutionStatus::Completed),
367 )
368 .await
369 {
370 Ok(Ok(())) => {}
371 Ok(Err(e)) => {
372 tracing::warn!(
373 error = %e,
374 "durable agent_turns: failed to finalize execution on conversation switch"
375 );
376 }
377 Err(_) => {
378 tracing::warn!(
379 "durable agent_turns: finalize timed out on conversation switch"
380 );
381 }
382 }
383 }
384 if let Some(h) = self.services.session.durable_writer_task.take() {
385 h.abort();
386 }
387 self.services.session.durable_ctx = None;
388 self.services.session.durable_writer = None;
389 self.services.session.durable_ctx_init_attempted = false;
390 self.services.session.durable_execution_lock = None;
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::{DurableKeyMaterial, RETENTION_TASK_NAME, open_durable_backend};
397 use crate::agent::agent_tests::*;
398
399 fn agent_with_conversation() -> crate::agent::Agent<MockChannel> {
400 let provider = mock_provider(vec!["ok".into()]);
401 let channel = MockChannel::new(vec![]);
402 let registry = create_test_registry();
403 let executor = MockToolExecutor::no_tools();
404 let mut agent = crate::agent::Agent::new(provider, channel, registry, None, 5, executor);
405 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(1));
406 agent
407 }
408
409 #[tokio::test]
410 async fn populates_durable_ctx_when_agent_turns_enabled() {
411 let mut agent = agent_with_conversation();
412 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
413 enabled: true,
414 agent_turns: true,
415 ..zeph_config::DurableConfig::default()
416 });
417 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
418
419 agent.ensure_session_durable_ctx().await;
420
421 assert!(agent.services.session.durable_ctx.is_some());
422 assert!(agent.services.session.durable_writer.is_some());
423 assert!(agent.services.session.durable_ctx_init_attempted);
424 }
425
426 #[tokio::test]
427 async fn spawns_retention_sweep_reachable_via_task_supervisor_snapshot() {
428 let mut agent = agent_with_conversation();
432 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
433 enabled: true,
434 agent_turns: true,
435 ..zeph_config::DurableConfig::default()
436 });
437 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
438
439 agent.ensure_session_durable_ctx().await;
440
441 let names: Vec<String> = agent
442 .runtime
443 .lifecycle
444 .task_supervisor
445 .snapshot()
446 .iter()
447 .map(|s| s.name.to_string())
448 .collect();
449 assert!(
450 names.contains(&RETENTION_TASK_NAME.to_owned()),
451 "expected {RETENTION_TASK_NAME:?} among supervised tasks, got {names:?}"
452 );
453 }
454
455 #[tokio::test]
463 async fn open_durable_backend_reads_previous_key_control_entry_through_rotation_window() {
464 use zeph_durable::Journal as _;
465
466 let dir = tempfile::tempdir().unwrap();
467 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
468 let current_key = [2u8; 32];
469 let previous_key = [1u8; 32];
470
471 let exec = zeph_durable::ExecutionId::new();
472 {
473 let pre_rotation_writer = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
474 .await
475 .unwrap()
476 .with_hmac_key(previous_key);
477 pre_rotation_writer.init().await.unwrap();
478 pre_rotation_writer
479 .open_execution(exec, zeph_durable::ExecutionKind::AgentTurn)
480 .await
481 .unwrap();
482 let step_id = zeph_durable::StepId::new(0);
483 pre_rotation_writer
484 .append(zeph_durable::JournalEntry {
485 seq: None,
486 execution_id: exec,
487 kind: zeph_durable::ExecutionKind::AgentTurn,
488 step_id,
489 entry: zeph_durable::EntryKind::EffectIntent {
490 idempotency_key: zeph_durable::IdempotencyKey::derive(
491 exec,
492 step_id,
493 b"transfer",
494 ),
495 effect: zeph_durable::EffectClass::ExactlyOnceGuarded,
496 hmac: None,
497 },
498 created_at_ms: 0,
499 })
500 .await
501 .unwrap();
502 }
503
504 let task_supervisor =
505 zeph_common::TaskSupervisor::new(tokio_util::sync::CancellationToken::new());
506 let cfg = zeph_config::DurableConfig::default();
507 let backend_result = open_durable_backend(
508 &task_supervisor,
509 "test.durable.journal_writer",
510 &cfg,
511 &db_url,
512 DurableKeyMaterial {
513 cipher: None,
514 hmac_key: Some(current_key),
515 hwm_key: None,
516 previous_hmac_key: Some(previous_key),
517 previous_hwm_key: None,
518 integrity_sealed: false,
519 integrity_grandfather: std::collections::HashSet::new(),
520 },
521 )
522 .await;
523 let (backend, _writer, _task_handle) =
524 backend_result.expect("backend must open with both HMAC keys attached");
525 let zeph_durable::DurableBackendEnum::Local(local) = &*backend else {
526 panic!("expected LocalBackend");
527 };
528 assert!(
529 local.read_execution(exec).await.is_ok(),
530 "the agent-replay (P1/P2) shared backend glue must verify a pre-rotation \
531 EffectIntent control entry through the rotation window"
532 );
533 }
534
535 #[tokio::test]
536 async fn stays_none_when_agent_turns_not_configured() {
537 let mut agent = agent_with_conversation();
540
541 agent.ensure_session_durable_ctx().await;
542
543 assert!(agent.services.session.durable_ctx.is_none());
544 assert!(agent.services.session.durable_ctx_init_attempted);
545 }
546
547 #[tokio::test]
548 async fn degrades_when_conversation_id_missing() {
549 let provider = mock_provider(vec!["ok".into()]);
552 let channel = MockChannel::new(vec![]);
553 let registry = create_test_registry();
554 let executor = MockToolExecutor::no_tools();
555 let mut agent = crate::agent::Agent::new(provider, channel, registry, None, 5, executor);
556 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
557 enabled: true,
558 agent_turns: true,
559 ..zeph_config::DurableConfig::default()
560 });
561 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
562
563 agent.ensure_session_durable_ctx().await;
564
565 assert!(agent.services.session.durable_ctx.is_none());
566 }
567
568 #[tokio::test]
569 async fn is_a_noop_after_first_attempt() {
570 let mut agent = agent_with_conversation();
571 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
572 enabled: true,
573 agent_turns: true,
574 ..zeph_config::DurableConfig::default()
575 });
576 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
577
578 agent.ensure_session_durable_ctx().await;
579 let first = agent
580 .services
581 .session
582 .durable_ctx
583 .clone()
584 .expect("durable_ctx should be populated");
585
586 agent.ensure_session_durable_ctx().await;
588 let second = agent
589 .services
590 .session
591 .durable_ctx
592 .clone()
593 .expect("durable_ctx should still be populated");
594 assert!(std::sync::Arc::ptr_eq(&first, &second));
595 }
596
597 #[tokio::test]
598 async fn conversation_switch_rebinds_execution_id() {
599 let mut agent = agent_with_conversation();
602 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
603 enabled: true,
604 agent_turns: true,
605 ..zeph_config::DurableConfig::default()
606 });
607 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
608
609 agent.ensure_session_durable_ctx().await;
610 let first_exec_id = agent
611 .services
612 .session
613 .durable_ctx
614 .as_ref()
615 .expect("durable_ctx should be populated")
616 .execution_id();
617
618 agent.reset_durable_ctx_for_conversation_switch().await;
620 assert!(
621 agent.services.session.durable_ctx.is_none(),
622 "durable_ctx must be cleared by the switch"
623 );
624 assert!(
625 !agent.services.session.durable_ctx_init_attempted,
626 "latch must be reset so the next call re-derives the execution"
627 );
628 agent.services.memory.persistence.conversation_id = Some(zeph_memory::ConversationId(2));
629
630 agent.ensure_session_durable_ctx().await;
631 let second_exec_id = agent
632 .services
633 .session
634 .durable_ctx
635 .as_ref()
636 .expect("durable_ctx should be repopulated for the new conversation")
637 .execution_id();
638
639 assert_ne!(
640 first_exec_id, second_exec_id,
641 "a conversation switch must rebind the P1 execution to the new conversation_id"
642 );
643 }
644
645 #[tokio::test]
646 async fn conversation_switch_finalizes_the_old_execution_as_completed() {
647 let dir = tempfile::tempdir().unwrap();
653 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
654
655 let mut agent = agent_with_conversation();
656 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
657 enabled: true,
658 agent_turns: true,
659 ..zeph_config::DurableConfig::default()
660 });
661 agent.services.session.durable_agent_turns_db_url = Some(db_url.clone());
662
663 agent.ensure_session_durable_ctx().await;
664 let old_exec_id = agent
665 .services
666 .session
667 .durable_ctx
668 .as_ref()
669 .expect("durable_ctx should be populated")
670 .execution_id();
671
672 agent.reset_durable_ctx_for_conversation_switch().await;
673
674 let backend = zeph_durable::LocalBackend::open(&db_url, 1_048_576)
675 .await
676 .unwrap();
677 let summaries = backend.list_executions(None, None, 10).await.unwrap();
678 let old = summaries
679 .iter()
680 .find(|s| s.execution_id == old_exec_id)
681 .expect("the old execution's row must still exist");
682 assert_eq!(
683 old.status,
684 zeph_durable::ExecutionStatus::Completed,
685 "the old conversation's execution must finalize as Completed on switch"
686 );
687 }
688
689 #[tokio::test]
690 async fn distinct_sqlite_paths_do_not_collide_on_first_conversation() {
691 async fn exec_id_for(sqlite_path: &str) -> zeph_durable::ExecutionId {
695 let mut agent = agent_with_conversation();
696 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
697 enabled: true,
698 agent_turns: true,
699 ..zeph_config::DurableConfig::default()
700 });
701 agent.services.session.durable_agent_turns_db_url = Some(":memory:".to_owned());
702 agent.services.session.durable_agent_turns_sqlite_path = Some(sqlite_path.to_owned());
703
704 agent.ensure_session_durable_ctx().await;
705 agent
706 .services
707 .session
708 .durable_ctx
709 .as_ref()
710 .expect("durable_ctx should be populated")
711 .execution_id()
712 }
713
714 let a = Box::pin(exec_id_for("/data/alpha/zeph.db")).await;
715 let b = Box::pin(exec_id_for("/data/beta/zeph.db")).await;
716
717 assert_ne!(
718 a, b,
719 "two databases' first conversation must not derive the same ExecutionId"
720 );
721 }
722
723 #[tokio::test]
724 async fn legacy_shared_durable_db_upgrade_path_does_not_collide() {
725 async fn bootstrap(
734 legacy_db_url: &str,
735 sqlite_path: &str,
736 ) -> crate::agent::Agent<MockChannel> {
737 let mut agent = agent_with_conversation();
738 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
739 enabled: true,
740 agent_turns: true,
741 ..zeph_config::DurableConfig::default()
742 });
743 agent.services.session.durable_agent_turns_db_url = Some(legacy_db_url.to_owned());
744 agent.services.session.durable_agent_turns_sqlite_path = Some(sqlite_path.to_owned());
745 agent.ensure_session_durable_ctx().await;
746 agent
747 }
748
749 let dir = tempfile::tempdir().unwrap();
750 let legacy_db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
751 let sqlite_a = dir.path().join("alpha.db").to_string_lossy().into_owned();
752 let sqlite_b = dir.path().join("beta.db").to_string_lossy().into_owned();
753
754 let agent_a = Box::pin(bootstrap(&legacy_db_url, &sqlite_a)).await;
756 let exec_a = agent_a
757 .services
758 .session
759 .durable_ctx
760 .as_ref()
761 .expect("DB A's durable_ctx should be populated")
762 .execution_id();
763
764 let agent_b = Box::pin(bootstrap(&legacy_db_url, &sqlite_b)).await;
767 let exec_b = agent_b
768 .services
769 .session
770 .durable_ctx
771 .as_ref()
772 .expect("DB B's durable_ctx should be populated")
773 .execution_id();
774
775 assert_ne!(
776 exec_a, exec_b,
777 "DB B's first conversation must not collide with DB A's execution in the shared legacy journal"
778 );
779
780 let backend = zeph_durable::LocalBackend::open(&legacy_db_url, 1_000_000)
783 .await
784 .expect("legacy journal file must be openable after both bootstraps");
785 let executions = backend
786 .list_executions(None, None, 10)
787 .await
788 .expect("list_executions must succeed");
789 assert_eq!(
790 executions.len(),
791 2,
792 "the shared legacy journal must contain two distinct executions, not a collapsed one"
793 );
794 }
795
796 #[tokio::test]
803 async fn concurrent_agents_on_same_conversation_do_not_collide() {
804 async fn bootstrap(db_url: &str, sqlite_path: &str) -> crate::agent::Agent<MockChannel> {
805 let mut agent = agent_with_conversation();
806 agent.services.session.durable_agent_turns_config = Some(zeph_config::DurableConfig {
807 enabled: true,
808 agent_turns: true,
809 ..zeph_config::DurableConfig::default()
810 });
811 agent.services.session.durable_agent_turns_db_url = Some(db_url.to_owned());
812 agent.services.session.durable_agent_turns_sqlite_path = Some(sqlite_path.to_owned());
813 agent.ensure_session_durable_ctx().await;
814 agent
815 }
816
817 let dir = tempfile::tempdir().unwrap();
818 let db_url = dir.path().join("durable.db").to_string_lossy().into_owned();
819 let sqlite_path = dir.path().join("zeph.db").to_string_lossy().into_owned();
820
821 let agent_a = Box::pin(bootstrap(&db_url, &sqlite_path)).await;
824 assert!(
825 agent_a.services.session.durable_ctx.is_some(),
826 "the first process must get a durable_ctx"
827 );
828 assert!(
829 agent_a.services.session.durable_execution_lock.is_some(),
830 "the first process must hold the execution lock"
831 );
832
833 let agent_b = Box::pin(bootstrap(&db_url, &sqlite_path)).await;
836 assert!(
837 agent_b.services.session.durable_ctx.is_none(),
838 "a second concurrent process on the same execution must degrade to non-durable"
839 );
840 assert!(
841 agent_b.services.session.durable_execution_lock.is_none(),
842 "a rejected process must not hold any lock"
843 );
844
845 let mut agent_a = agent_a;
847 agent_a.reset_durable_ctx_for_conversation_switch().await;
848 let agent_c = Box::pin(bootstrap(&db_url, &sqlite_path)).await;
849 assert!(
850 agent_c.services.session.durable_ctx.is_some(),
851 "after the lock holder releases, a later process must be able to open the execution"
852 );
853 }
854}