Skip to main content

zeph_core/agent/
durable_bootstrap.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared durable-backend construction, used by both the P1 (agent-turn) and P2
5//! (orchestration) durable adapters so backend/writer setup stays consistent across every
6//! adapter that reads the shared `[durable]` config section (#5452).
7//!
8//! This module owns only the mechanical "open backend, init schema, attach cipher, spawn
9//! writer, spawn retention sweep" sequence. Each adapter keeps its own cache slot
10//! (`services.orchestration.durable_*` for P2, `services.session.durable_*` for P1) and its
11//! own [`zeph_durable::ExecutionId`] derivation — those decisions are adapter-specific and stay
12//! in `plan.rs` / `durable_bootstrap.rs` respectively.
13
14use 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
25/// Supervised task name for the background retention sweep (#6264).
26///
27/// Both the P1 and P2 adapters share one `TaskSupervisor` (`runtime.lifecycle.task_supervisor`)
28/// and, in the common case, the same on-disk `durable.db`. Using one fixed name lets
29/// `TaskSupervisor::spawn`'s "same name aborts the prior instance" rule collapse a second
30/// adapter's spawn into a plain restart of the first adapter's sweep, instead of running two
31/// redundant sweeps against the same journal.
32const RETENTION_TASK_NAME: &str = "durable.retention_sweep";
33
34/// Key material and integrity-seal state shared by every durable-backend construction call
35/// site: `open_durable_backend`, the P1/P2 `AgentBuilder::with_durable_*` methods
36/// (`builder.rs`), and their reassembly points in `Agent::ensure_session_durable_ctx` and
37/// `plan.rs`'s `ensure_durable_backend` (#6458).
38///
39/// `hmac_key` is `None` for a single-user local, non-shared database (INV-8) — the documented
40/// stance where control entries carry no HMAC. `hwm_key` (issue #6360) is meant to be attached
41/// unconditionally (FR-009): `None` only when `ZEPH_DURABLE_KEY` itself is unavailable — unlike
42/// `hmac_key`, single-user local deployments still get high-water-mark deletion detection.
43/// `previous_hmac_key` is `Some` only while a `zeph durable rotate-key` rotation window is open
44/// (#6451). `previous_hwm_key` is the HWM-side counterpart, `Some` under the same condition
45/// (addendum to #6451): unlike `previous_hmac_key`, its epoch reuses the AEAD cipher's `key_id`
46/// lifecycle rather than being epoch-less try-both (see `HwmKeySlot`/`with_previous_hwm_key` in
47/// `zeph-durable`). `integrity_sealed`/`integrity_grandfather` (issue #6449) are resolved from
48/// the vault by `crate::commands::durable::load_integrity_seal` in the `zeph` binary crate.
49///
50/// A mis-wired key field here — wrong key, wrong slot, or an unintended `None` — never results in
51/// a silent accept: every control-entry and high-water-mark verification this key material feeds
52/// fails closed, surfacing as
53/// [`ControlIntegrity`](zeph_durable::DurableError::ControlIntegrity) or
54/// [`HighWaterMarkIntegrity`](zeph_durable::DurableError::HighWaterMarkIntegrity) rather than a
55/// silently accepted read.
56///
57/// Deliberately does not derive `Debug`: every key field holds raw key-material bytes that must
58/// never be logged or printed (see project pitfall: secret-bearing `Debug` derives).
59///
60/// # Examples
61///
62/// ```
63/// use zeph_core::DurableKeyMaterial;
64///
65/// // A non-durable / disabled-encryption configuration: every key slot empty.
66/// let key_material = DurableKeyMaterial {
67///     cipher: None,
68///     hmac_key: None,
69///     hwm_key: None,
70///     previous_hmac_key: None,
71///     previous_hwm_key: None,
72///     integrity_sealed: false,
73///     integrity_grandfather: Default::default(),
74/// };
75/// assert!(key_material.hmac_key.is_none());
76/// ```
77pub struct DurableKeyMaterial {
78    /// AEAD payload cipher; `None` when `config.encrypt_payload = false` (development mode only).
79    pub cipher: Option<Arc<dyn PayloadCipher>>,
80    /// Current control-entry HMAC key.
81    pub hmac_key: Option<[u8; 32]>,
82    /// Current high-water-mark key as `(epoch, key)`.
83    pub hwm_key: Option<(u32, [u8; 32])>,
84    /// Previous control-entry HMAC key, valid only during an open rotation window.
85    pub previous_hmac_key: Option<[u8; 32]>,
86    /// Previous high-water-mark key as `(epoch, key)`, valid only during an open rotation window.
87    pub previous_hwm_key: Option<(u32, [u8; 32])>,
88    /// Whether the durable integrity seal is set.
89    pub integrity_sealed: bool,
90    /// Executions grandfathered in before the integrity seal was set, exempt from verification.
91    pub integrity_grandfather: std::collections::HashSet<ExecutionId>,
92}
93
94/// Open a [`LocalBackend`] at `db_url`, initialise its schema, attach the key material in
95/// `key_material` if present, spawn its [`JournalWriter`](zeph_durable::JournalWriter) actor, and
96/// spawn the background [`DurableRetentionService`] prune sweep — all via `task_supervisor`.
97///
98/// See [`DurableKeyMaterial`] for the meaning of each field.
99///
100/// Returns `None` (after logging a `tracing::warn!`) on any I/O failure so callers degrade to
101/// non-durable mode rather than fail session bootstrap (#5452 FR-004).
102pub(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    /// Lazily construct the session's [`DurableContext`](zeph_durable::DurableContext) for the
189    /// P1 agent-turn adapter (#5452), the first time a durable-gated call site needs it.
190    ///
191    /// Deferred to first use (rather than built eagerly in the `AgentBuilder` chain) because the
192    /// real, shutdown-linked `TaskSupervisor` is only attached via `with_task_supervisor` late in
193    /// bootstrap — constructing here (well after `.build()`) guarantees the journal-writer actor
194    /// spawns onto the correct supervisor. A no-op after the first attempt (success or failure):
195    /// `durable_ctx_init_attempted` suppresses retrying I/O on every subsequent turn.
196    ///
197    /// The execution is keyed on the session's `ConversationId` (not per-turn), so every turn in
198    /// the session journals as a step within the *same* execution and a crash mid-session can
199    /// resume from any prior turn's journal state.
200    ///
201    /// `#[allow(clippy::too_many_lines)]`: the `open_execution` / advisory-lock / `DurableContext`
202    /// construction sequence in this function's body is a single linear bootstrap that stays
203    /// past the line budget regardless of how the key-material parameters are threaded;
204    /// splitting it into sub-functions would add indirection with no readability gain.
205    #[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        // Fold `sqlite_path` in alongside the fixed-width `ConversationId` bytes so that even if
271        // two distinct memory databases were ever configured to share the same durable journal
272        // `db_url`, their first-ever conversation (always `ConversationId(1)`) still cannot
273        // derive the same `ExecutionId` (#5553). The journal-file-per-database fix in
274        // `resolve_durable_db_url` already prevents the collision in the common case; this is
275        // defense in depth for that derivation.
276        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        // `_exclusive` acquires a process-scoped advisory lock on `exec_id` before touching the
281        // row (INV-15, #6122): two processes that derive the same `exec_id` (e.g. two CLI
282        // instances sharing `memory.sqlite_path` and resolving the same latest `ConversationId`)
283        // can no longer both drive the execution concurrently. The lock is held in
284        // `durable_execution_lock` for as long as `durable_ctx` is `Some`.
285        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    /// Detach the P1 durable execution before a conversation switch (`/new`, `/conv resume`,
333    /// `/conv fork` — #5452 critic finding S1).
334    ///
335    /// `ensure_session_durable_ctx` keys its `ExecutionId` on `ConversationId` and then latches
336    /// `durable_ctx_init_attempted` so it never re-derives the execution again. Without this
337    /// reset, every turn after a conversation switch would keep journaling under the *old*
338    /// conversation's execution — silently mixing two conversations' turn state and defeating the
339    /// per-conversation crash-resume the keying is meant to provide. Flushes the old writer,
340    /// finalizes the old execution as `Completed` (best-effort — this session is done with it, but
341    /// per #6251 a later `/conv resume` back to it reopens and un-finalizes the row, so nothing is
342    /// lost), then aborts the writer task (same 2s deadline as `flush_durable_writer` on shutdown)
343    /// before clearing the session's durable fields — including `durable_execution_lock` (INV-15,
344    /// #6122), releasing the old execution's advisory lock so another process (or a later switch
345    /// back in this same process) may open it — so the next durable-gated call re-derives a fresh
346    /// execution for the new `conversation_id`.
347    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        // #6264: `DurableRetentionService::run()` must actually be reachable from production
429        // startup, not just constructible. Assert the supervised task shows up in
430        // `TaskSupervisor::snapshot()` — the same registry the TUI task panel (#6281) reads.
431        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    /// #6451 regression (critic finding 2): agent replay is one of the three runtime read
456    /// channels that must keep verifying a pre-rotation `EffectIntent` control entry through an
457    /// open rotation window. `open_durable_backend` is the shared glue both the P1 (agent-turn)
458    /// and P2 (orchestration) adapters route through (see the module doc), so exercising it
459    /// directly covers both. `EffectIntent` never carries a payload, so this also models the
460    /// payload-less crash-orphan shape the HMAC drop-scan exists for — the AEAD blob-scan alone
461    /// could never have caught a missed `previous_hmac_key` wiring here.
462    #[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        // FR-002: no `with_durable_agent_turns` call at all (the builder-level gate), so the
538        // session's stash fields are `None` — mirrors a plain `[durable] enabled=false` deployment.
539        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        // FR-004: construction must not panic or hard-fail bootstrap when the conversation_id
550        // gate can't be satisfied — it degrades to non-durable instead.
551        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        // Second call must not reconstruct — same Arc instance, no panic on double-init.
587        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        // Regression test for critic finding S1: a conversation switch must not leave the P1
600        // execution bound to the stale (old) ConversationId.
601        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        // Simulate `reset_conversation`'s durable-detach step, then the new conversation_id.
619        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        // #6251: a conversation switch must finalize the *old* conversation's P1 execution as
648        // `Completed`, otherwise it stays `running` forever and the retention sweep can never
649        // reclaim it. `:memory:` can't be re-opened from a second connection to verify this, so
650        // this test uses a real file-backed sqlite db instead (same pattern as
651        // `legacy_shared_durable_db_upgrade_path_does_not_collide` below).
652        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        // Regression test for #5553: two agents pointed at different memory databases (but
692        // sharing the same durable `db_url`, e.g. via directory collision) must not derive the
693        // same `ExecutionId` for their respective first-ever `ConversationId(1)`.
694        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        // Regression for #5553's "upgrade" scenario: a directory already has a legacy bare
726        // `durable.db` (the pre-fix layout), so `resolve_durable_db_url` (src/commands/durable.rs)
727        // deliberately keeps every database in that directory pointed at the *same* legacy file
728        // rather than namespacing it — this is the one path where the file-separation half of the
729        // fix does NOT kick in. The `ExecutionId` fold over `sqlite_path` (the defense-in-depth
730        // half, exercised here through the real production code path) is the only thing that
731        // still prevents a second database's first-ever conversation from colliding with the
732        // first database's execution already journaled in that shared file.
733        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        // DB A runs first, journaling its first-conversation execution into the legacy file.
755        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        // DB B is a distinct database but, per the legacy-preferred branch of
765        // `resolve_durable_db_url`, resolves to the SAME shared journal file.
766        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        // Confirm both landed as two genuinely distinct rows in the shared file, not one
781        // execution spuriously "resumed" by the other.
782        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    /// Regression test for #6122: two agent processes sharing the same `memory.sqlite_path` and
797    /// resolving the same (first-ever) `ConversationId` derive byte-for-byte identical
798    /// `ExecutionId`s by design (#5553's fold is only a cross-*database* discriminator). Before
799    /// the fix, both processes' `open_execution` would race the same row and both would drive
800    /// `next_step` from 0 against the same journal. The second process must now be rejected with
801    /// a clear degrade instead of silently corrupting the shared execution.
802    #[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        // Process A: same conversation_id (ConversationId(1) via agent_with_conversation), same
822        // sqlite_path, same db_url — wins the race and keeps its durable_ctx + lock.
823        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        // Process B: identical derivation inputs -> identical ExecutionId. Must degrade to
834        // non-durable rather than silently racing process A's journal.
835        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        // Once A releases the lock (conversation switch / drop), a later process may open it.
846        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}