Skip to main content

bamboo_domain/session/
persistence.rs

1use std::io;
2use std::sync::Arc;
3
4use crate::session::types::Session;
5
6/// Merge messages from a live runner snapshot into an already-durable
7/// transcript without ever removing or rewriting a durable message.
8///
9/// Runtime sessions are append-oriented, and every newly-created message has a
10/// stable id.  A runner may nevertheless be holding a snapshot that predates a
11/// concurrent append (for example, an injected child-completion message).  A
12/// terminal/error checkpoint must not full-save that stale snapshot: doing so
13/// would shrink the transcript.  Keep the durable ordering and append only the
14/// live messages whose ids are not durable yet.
15pub fn append_missing_runtime_messages(session: &mut Session, durable: &Session) -> usize {
16    let mut seen = durable
17        .messages
18        .iter()
19        .map(|message| message.id.clone())
20        .collect::<std::collections::HashSet<_>>();
21    let missing = session
22        .messages
23        .iter()
24        .filter(|message| seen.insert(message.id.clone()))
25        .cloned()
26        .collect::<Vec<_>>();
27    let appended = missing.len();
28    session.messages = durable.messages.iter().cloned().chain(missing).collect();
29    appended
30}
31
32/// Merge the durable SessionInbox admitted-id cursor into a writer snapshot.
33///
34/// Runtime writers can hold a session clone from before another run admitted
35/// an inbox message. No later full save may erase that durable dedupe state.
36pub fn merge_session_inbox_admission(session: &mut Session, durable: &Session) {
37    let Some(durable_state) = durable.session_inbox_admission().cloned() else {
38        return;
39    };
40    session
41        .session_inbox_admission_mut()
42        .merge_from(&durable_state);
43}
44
45/// Restore durable provider messages identified by their typed
46/// `metadata.session_message` marker into a stale writer without preserving
47/// unrelated durable suffixes. The bounded cursor is only a fast recent index;
48/// the transcript marker is the unbounded source of truth after cursor
49/// eviction.
50///
51/// Insertion follows durable transcript neighbors so an admitted user/runtime
52/// message remains ahead of any later assistant output held by the stale
53/// runner. This is narrower than [`append_missing_runtime_messages`], retaining
54/// the historical shrink semantics for unrelated concurrent messages while
55/// making a cursor/tombstone incapable of outliving its transcript entry.
56pub fn restore_missing_admitted_inbox_messages(session: &mut Session, durable: &Session) -> usize {
57    let admission = durable.session_inbox_admission();
58    let mut restored = 0;
59    for (durable_index, message) in durable.messages.iter().enumerate() {
60        let typed_marker = message
61            .metadata
62            .as_ref()
63            .and_then(|metadata| metadata.get("session_message"))
64            .is_some_and(|marker| {
65                marker.get("id").and_then(serde_json::Value::as_str) == Some(message.id.as_str())
66                    && marker
67                        .get("target_session_id")
68                        .and_then(serde_json::Value::as_str)
69                        == Some(durable.id.as_str())
70                    && crate::SessionMessageId::parse(message.id.clone()).is_ok()
71            });
72        let recent_cursor = admission.is_some_and(|state| state.contains_str(&message.id));
73        if !(typed_marker || recent_cursor)
74            || session
75                .messages
76                .iter()
77                .any(|current| current.id == message.id)
78        {
79            continue;
80        }
81
82        let insertion = durable.messages[..durable_index]
83            .iter()
84            .rev()
85            .find_map(|predecessor| {
86                session
87                    .messages
88                    .iter()
89                    .position(|current| current.id == predecessor.id)
90                    .map(|index| index + 1)
91            })
92            .or_else(|| {
93                durable.messages[durable_index + 1..]
94                    .iter()
95                    .find_map(|successor| {
96                        session
97                            .messages
98                            .iter()
99                            .position(|current| current.id == successor.id)
100                    })
101            })
102            .unwrap_or(session.messages.len());
103        session.messages.insert(insertion, message.clone());
104        restored += 1;
105    }
106    restored
107}
108
109/// Port for runtime (non-authoritative) session persistence.
110///
111/// Implementors must:
112/// - Serialize concurrent saves per session ID.
113/// - Merge on-disk authoritative metadata (`title`, `pinned`, `title_version`,
114///   `metadata_version`) before writing, so UI edits are never clobbered.
115#[async_trait::async_trait]
116pub trait RuntimeSessionPersistence: Send + Sync {
117    /// Persist the session, merging any newer authoritative metadata from disk.
118    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()>;
119
120    /// Append-safe checkpoint used at the shared engine execute boundary.
121    ///
122    /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
123    /// preserve messages that were appended durably by a concurrent writer
124    /// after the runner loaded its snapshot.  Implementations that can provide
125    /// a per-session transaction should override this method and perform the
126    /// load/merge/save under one lock.  The default still reconciles against a
127    /// latest snapshot for lightweight/custom SDK persisters; the built-in
128    /// storage implementation supplies the atomic variant.
129    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
130        if let Some(durable) = self.load_runtime_session(&session.id).await? {
131            append_missing_runtime_messages(session, &durable);
132            merge_session_inbox_admission(session, &durable);
133        }
134        self.save_runtime_session(session).await
135    }
136
137    /// Load the latest runtime-visible session snapshot when the persistence
138    /// implementation can coordinate reads. Tools may update a repository-owned
139    /// clone while an agent loop holds its own live Session; the loop uses this
140    /// hook to merge narrowly-scoped tool side effects before its next save.
141    async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
142        Ok(None)
143    }
144
145    /// Clear the bounded compatibility queue iff it still equals the entries
146    /// that were durably copied into SessionInbox. Implementations with a
147    /// per-session transaction should override this method.
148    async fn clear_legacy_pending_messages(
149        &self,
150        session_id: &str,
151        expected: &[serde_json::Value],
152    ) -> io::Result<bool> {
153        let Some(mut latest) = self.load_runtime_session(session_id).await? else {
154            return Ok(false);
155        };
156        if latest.pending_injected_messages().as_deref() != Some(expected) {
157            return Ok(false);
158        }
159        latest.clear_pending_injected_messages();
160        self.save_runtime_session(&mut latest).await?;
161        Ok(true)
162    }
163
164    /// Append one JSON-line analysis record to the session's append-only
165    /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
166    /// a no-op so non-file-backed persisters are unaffected.
167    ///
168    /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
169    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
170        let _ = (session_id, json_line);
171        Ok(())
172    }
173}
174
175#[async_trait::async_trait]
176impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
177    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
178        (**self).save_runtime_session(session).await
179    }
180
181    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
182        (**self).checkpoint_runtime_session(session).await
183    }
184
185    async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
186        (**self).load_runtime_session(session_id).await
187    }
188
189    async fn clear_legacy_pending_messages(
190        &self,
191        session_id: &str,
192        expected: &[serde_json::Value],
193    ) -> io::Result<bool> {
194        (**self)
195            .clear_legacy_pending_messages(session_id, expected)
196            .await
197    }
198
199    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
200        (**self)
201            .append_token_usage_record(session_id, json_line)
202            .await
203    }
204}