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}