bamboo_domain/session/persistence.rs
1use std::io;
2use std::sync::Arc;
3
4use crate::session::task::TaskList;
5use crate::session::types::Session;
6use crate::session::PermissionAuditSeed;
7
8/// Merge messages from a live runner snapshot into an already-durable
9/// transcript without ever removing or rewriting a durable message.
10///
11/// Runtime sessions are append-oriented, and every newly-created message has a
12/// stable id. A runner may nevertheless be holding a snapshot that predates a
13/// concurrent append (for example, an injected child-completion message). A
14/// terminal/error checkpoint must not full-save that stale snapshot: doing so
15/// would shrink the transcript. Keep the durable ordering and append only the
16/// live messages whose ids are not durable yet.
17pub fn append_missing_runtime_messages(session: &mut Session, durable: &Session) -> usize {
18 let mut seen = durable
19 .messages
20 .iter()
21 .map(|message| message.id.clone())
22 .collect::<std::collections::HashSet<_>>();
23 let missing = session
24 .messages
25 .iter()
26 .filter(|message| seen.insert(message.id.clone()))
27 .cloned()
28 .collect::<Vec<_>>();
29 let appended = missing.len();
30 session.messages = durable.messages.iter().cloned().chain(missing).collect();
31 appended
32}
33
34/// Merge the durable SessionInbox admitted-id cursor into a writer snapshot.
35///
36/// Runtime writers can hold a session clone from before another run admitted
37/// an inbox message. No later full save may erase that durable dedupe state.
38pub fn merge_session_inbox_admission(session: &mut Session, durable: &Session) {
39 let Some(durable_state) = durable.session_inbox_admission().cloned() else {
40 return;
41 };
42 session
43 .session_inbox_admission_mut()
44 .merge_from(&durable_state);
45}
46
47/// Restore durable provider messages identified by their typed
48/// `metadata.session_message` marker into a stale writer without preserving
49/// unrelated durable suffixes. The bounded cursor is only a fast recent index;
50/// the transcript marker is the unbounded source of truth after cursor
51/// eviction.
52///
53/// Insertion follows durable transcript neighbors so an admitted user/runtime
54/// message remains ahead of any later assistant output held by the stale
55/// runner. This is narrower than [`append_missing_runtime_messages`], retaining
56/// the historical shrink semantics for unrelated concurrent messages while
57/// making a cursor/tombstone incapable of outliving its transcript entry.
58pub fn restore_missing_admitted_inbox_messages(session: &mut Session, durable: &Session) -> usize {
59 let admission = durable.session_inbox_admission();
60 let mut restored = 0;
61 for (durable_index, message) in durable.messages.iter().enumerate() {
62 let typed_marker = message
63 .metadata
64 .as_ref()
65 .and_then(|metadata| metadata.get("session_message"))
66 .is_some_and(|marker| {
67 marker.get("id").and_then(serde_json::Value::as_str) == Some(message.id.as_str())
68 && marker
69 .get("target_session_id")
70 .and_then(serde_json::Value::as_str)
71 == Some(durable.id.as_str())
72 && crate::SessionMessageId::parse(message.id.clone()).is_ok()
73 });
74 let recent_cursor = admission.is_some_and(|state| state.contains_str(&message.id));
75 if !(typed_marker || recent_cursor)
76 || session
77 .messages
78 .iter()
79 .any(|current| current.id == message.id)
80 {
81 continue;
82 }
83
84 let insertion = durable.messages[..durable_index]
85 .iter()
86 .rev()
87 .find_map(|predecessor| {
88 session
89 .messages
90 .iter()
91 .position(|current| current.id == predecessor.id)
92 .map(|index| index + 1)
93 })
94 .or_else(|| {
95 durable.messages[durable_index + 1..]
96 .iter()
97 .find_map(|successor| {
98 session
99 .messages
100 .iter()
101 .position(|current| current.id == successor.id)
102 })
103 })
104 .unwrap_or(session.messages.len());
105 session.messages.insert(insertion, message.clone());
106 restored += 1;
107 }
108 restored
109}
110
111/// Port for runtime (non-authoritative) session persistence.
112///
113/// Implementors must:
114/// - Serialize concurrent saves per session ID.
115/// - Merge on-disk authoritative metadata (`title`, `pinned`, `title_version`,
116/// `metadata_version`) before writing, so UI edits are never clobbered.
117#[async_trait::async_trait]
118pub trait RuntimeSessionPersistence: Send + Sync {
119 /// Persist the session, merging any newer authoritative metadata from disk.
120 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()>;
121
122 /// Authoritatively seed one validated actor activation.
123 ///
124 /// Unlike an ordinary runtime save, the incoming RunSpec posture and its
125 /// complete audit record must replace any posture left by a previous warm
126 /// activation. Implementations must still preserve durable SessionInbox
127 /// admission/transcript proof and serialize the operation per session.
128 ///
129 /// There is no safe generic implementation through
130 /// [`Self::save_runtime_session`]: that primitive is explicitly allowed to
131 /// adopt a newer disk posture, which would make warm workers sticky across
132 /// runs. Custom persisters therefore fail closed until they implement this
133 /// authority boundary deliberately.
134 async fn seed_runtime_activation(&self, _session: &mut Session) -> io::Result<()> {
135 Err(io::Error::new(
136 io::ErrorKind::Unsupported,
137 "runtime persistence does not support authoritative activation seeding",
138 ))
139 }
140
141 /// Atomically persist a worker-declared executor mapping for the current
142 /// host-authoritative permission posture.
143 ///
144 /// The caller supplies the audit revision it observed before dispatch.
145 /// Implementations must load and compare that revision while holding the
146 /// per-session lock, reject a concurrent posture update, and allocate a new
147 /// host revision/timestamp themselves. Remote audit clocks are never an
148 /// authority at this boundary.
149 async fn record_permission_posture_activation(
150 &self,
151 _session_id: &str,
152 _expected_audit_revision: Option<u64>,
153 _seed: &PermissionAuditSeed,
154 ) -> io::Result<Option<Session>> {
155 Err(io::Error::new(
156 io::ErrorKind::Unsupported,
157 "runtime persistence does not support atomic permission posture activation",
158 ))
159 }
160
161 /// Persist only the runtime control-plane for a session.
162 ///
163 /// Task lists and other runtime metadata belong to the control-plane and do
164 /// not require rewriting the potentially large message transcript. Built-in
165 /// persistence implementations with a runtime sidecar should override this
166 /// operation with their sidecar-only path. Custom/legacy implementations
167 /// remain source-compatible and safely fall back to the full runtime save.
168 ///
169 /// Callers must not rely on this operation to persist message changes.
170 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
171 self.save_runtime_session(session).await
172 }
173
174 /// Load the representation paired with
175 /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
176 ///
177 /// Sidecar-capable implementations should return their message-free
178 /// control-plane snapshot. The default deliberately returns the full
179 /// runtime session: when the paired save also falls back to a full save,
180 /// retaining the transcript makes that fallback safe rather than replacing
181 /// durable messages with an empty sidecar-shaped snapshot.
182 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
183 self.load_runtime_session(session_id).await
184 }
185
186 /// Atomically update only the shared Task list and its version.
187 ///
188 /// The default is safe for custom/legacy persistence: it loads the full
189 /// runtime session, changes only Task-owned fields, then uses the paired
190 /// control-plane save (which itself defaults to a full save). Returning
191 /// `false` means the implementation could not load the target; callers that
192 /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
193 /// behavior with an explicit full-load/full-save fallback.
194 ///
195 /// Implementations with per-session transactions should override this so
196 /// the load, narrow mutation and save share one critical section.
197 async fn update_task_list_control_plane(
198 &self,
199 session_id: &str,
200 task_list: &TaskList,
201 version: &str,
202 ) -> io::Result<bool> {
203 let Some(mut session) = self.load_runtime_session(session_id).await? else {
204 return Ok(false);
205 };
206 session.set_task_list(task_list.clone());
207 session.set_task_list_version_meta(version.to_string());
208 self.save_runtime_control_plane(&mut session).await?;
209 Ok(true)
210 }
211
212 /// Append-safe checkpoint used at the shared engine execute boundary.
213 ///
214 /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
215 /// preserve messages that were appended durably by a concurrent writer
216 /// after the runner loaded its snapshot. Implementations that can provide
217 /// a per-session transaction should override this method and perform the
218 /// load/merge/save under one lock. The default still reconciles against a
219 /// latest snapshot for lightweight/custom SDK persisters; the built-in
220 /// storage implementation supplies the atomic variant.
221 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
222 if let Some(durable) = self.load_runtime_session(&session.id).await? {
223 append_missing_runtime_messages(session, &durable);
224 merge_session_inbox_admission(session, &durable);
225 }
226 self.save_runtime_session(session).await
227 }
228
229 /// Load the latest runtime-visible session snapshot when the persistence
230 /// implementation can coordinate reads. Tools may update a repository-owned
231 /// clone while an agent loop holds its own live Session; the loop uses this
232 /// hook to merge narrowly-scoped tool side effects before its next save.
233 async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
234 Ok(None)
235 }
236
237 /// Clear the bounded compatibility queue iff it still equals the entries
238 /// that were durably copied into SessionInbox. Implementations with a
239 /// per-session transaction should override this method.
240 async fn clear_legacy_pending_messages(
241 &self,
242 session_id: &str,
243 expected: &[serde_json::Value],
244 ) -> io::Result<bool> {
245 let Some(mut latest) = self.load_runtime_session(session_id).await? else {
246 return Ok(false);
247 };
248 if latest.pending_injected_messages().as_deref() != Some(expected) {
249 return Ok(false);
250 }
251 latest.clear_pending_injected_messages();
252 self.save_runtime_session(&mut latest).await?;
253 Ok(true)
254 }
255
256 /// Append one JSON-line analysis record to the session's append-only
257 /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
258 /// a no-op so non-file-backed persisters are unaffected.
259 ///
260 /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
261 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
262 let _ = (session_id, json_line);
263 Ok(())
264 }
265}
266
267#[async_trait::async_trait]
268impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
269 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
270 (**self).save_runtime_session(session).await
271 }
272
273 async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
274 (**self).seed_runtime_activation(session).await
275 }
276
277 async fn record_permission_posture_activation(
278 &self,
279 session_id: &str,
280 expected_audit_revision: Option<u64>,
281 seed: &PermissionAuditSeed,
282 ) -> io::Result<Option<Session>> {
283 (**self)
284 .record_permission_posture_activation(session_id, expected_audit_revision, seed)
285 .await
286 }
287
288 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
289 (**self).save_runtime_control_plane(session).await
290 }
291
292 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
293 (**self).load_runtime_control_plane(session_id).await
294 }
295
296 async fn update_task_list_control_plane(
297 &self,
298 session_id: &str,
299 task_list: &TaskList,
300 version: &str,
301 ) -> io::Result<bool> {
302 (**self)
303 .update_task_list_control_plane(session_id, task_list, version)
304 .await
305 }
306
307 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
308 (**self).checkpoint_runtime_session(session).await
309 }
310
311 async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
312 (**self).load_runtime_session(session_id).await
313 }
314
315 async fn clear_legacy_pending_messages(
316 &self,
317 session_id: &str,
318 expected: &[serde_json::Value],
319 ) -> io::Result<bool> {
320 (**self)
321 .clear_legacy_pending_messages(session_id, expected)
322 .await
323 }
324
325 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
326 (**self)
327 .append_token_usage_record(session_id, json_line)
328 .await
329 }
330}