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`, `title_generated`, `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 or
170 /// `model_context_state` changes. The durable ledger is checkpoint-owned;
171 /// sidecar implementations must preserve its latest committed value while
172 /// applying the caller's narrow control-plane mutation.
173 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
174 self.save_runtime_session(session).await
175 }
176
177 /// Load the representation paired with
178 /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
179 ///
180 /// Sidecar-capable implementations should return their message-free
181 /// control-plane snapshot. The default deliberately returns the full
182 /// runtime session: when the paired save also falls back to a full save,
183 /// retaining the transcript makes that fallback safe rather than replacing
184 /// durable messages with an empty sidecar-shaped snapshot.
185 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
186 self.load_runtime_session(session_id).await
187 }
188
189 /// Atomically update only the shared Task list and its version.
190 ///
191 /// The default is safe for custom/legacy persistence: it loads the full
192 /// runtime session, changes only Task-owned fields, then uses the paired
193 /// control-plane save (which itself defaults to a full save). Returning
194 /// `false` means the implementation could not load the target; callers that
195 /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
196 /// behavior with an explicit full-load/full-save fallback.
197 ///
198 /// Implementations with per-session transactions should override this so
199 /// the load, narrow mutation and save share one critical section.
200 async fn update_task_list_control_plane(
201 &self,
202 session_id: &str,
203 task_list: &TaskList,
204 version: &str,
205 ) -> io::Result<bool> {
206 let Some(mut session) = self.load_runtime_session(session_id).await? else {
207 return Ok(false);
208 };
209 session.set_task_list(task_list.clone());
210 session.set_task_list_version_meta(version.to_string());
211 self.save_runtime_control_plane(&mut session).await?;
212 Ok(true)
213 }
214
215 /// Atomically update Task-owned control-plane fields only when the durable
216 /// Task generation and exact list still match the expected snapshot.
217 ///
218 /// `false` covers an unsupported atomic compare-and-patch, a missing target,
219 /// or a version conflict. Callers must treat it as a stale write and must
220 /// not publish their staged Task state. The default fails closed because a
221 /// load followed by a separately locked save is not an atomic CAS.
222 async fn update_task_list_control_plane_if_version(
223 &self,
224 session_id: &str,
225 expected_version: &str,
226 expected_task_list: &TaskList,
227 task_list: &TaskList,
228 version: &str,
229 ) -> io::Result<bool> {
230 let _ = (
231 session_id,
232 expected_version,
233 expected_task_list,
234 task_list,
235 version,
236 );
237 Ok(false)
238 }
239
240 /// Recoverably compare-and-patch the executing session and its shared root.
241 /// Implementations must validate both generations before either target is
242 /// written and may return `Ok(true)` only after both Task generations are
243 /// durable with no undo record that could later revert them. An error after
244 /// one physical write must restore both originals before returning or retain
245 /// durable recovery state and fail subsequent paired access closed until
246 /// recovery completes. Root-session callers pass the same id twice and
247 /// receive the single-target CAS semantics above.
248 async fn update_task_list_control_planes_if_version(
249 &self,
250 session_id: &str,
251 shared_session_id: &str,
252 expected_version: &str,
253 expected_task_list: &TaskList,
254 task_list: &TaskList,
255 version: &str,
256 ) -> io::Result<bool> {
257 if session_id == shared_session_id {
258 return self
259 .update_task_list_control_plane_if_version(
260 session_id,
261 expected_version,
262 expected_task_list,
263 task_list,
264 version,
265 )
266 .await;
267 }
268 let _ = (
269 session_id,
270 shared_session_id,
271 expected_version,
272 expected_task_list,
273 task_list,
274 version,
275 );
276 Ok(false)
277 }
278
279 /// Append-safe checkpoint used at the shared engine execute boundary.
280 ///
281 /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
282 /// preserve messages that were appended durably by a concurrent writer
283 /// after the runner loaded its snapshot. Implementations that can provide
284 /// a per-session transaction should override this method and perform the
285 /// load/merge/save under one lock. The default still reconciles against a
286 /// latest snapshot for lightweight/custom SDK persisters; the built-in
287 /// storage implementation supplies the atomic variant.
288 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
289 if let Some(durable) = self.load_runtime_session(&session.id).await? {
290 append_missing_runtime_messages(session, &durable);
291 merge_session_inbox_admission(session, &durable);
292 }
293 self.save_runtime_session(session).await
294 }
295
296 /// Load the latest runtime-visible session snapshot when the persistence
297 /// implementation can coordinate reads. Tools may update a repository-owned
298 /// clone while an agent loop holds its own live Session; the loop uses this
299 /// hook to merge narrowly-scoped tool side effects before its next save.
300 async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
301 Ok(None)
302 }
303
304 /// Clear the bounded compatibility queue iff it still equals the entries
305 /// that were durably copied into SessionInbox. Implementations with a
306 /// per-session transaction should override this method.
307 async fn clear_legacy_pending_messages(
308 &self,
309 session_id: &str,
310 expected: &[serde_json::Value],
311 ) -> io::Result<bool> {
312 let Some(mut latest) = self.load_runtime_session(session_id).await? else {
313 return Ok(false);
314 };
315 if latest.pending_injected_messages().as_deref() != Some(expected) {
316 return Ok(false);
317 }
318 latest.clear_pending_injected_messages();
319 self.save_runtime_session(&mut latest).await?;
320 Ok(true)
321 }
322
323 /// Append one JSON-line analysis record to the session's append-only
324 /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
325 /// a no-op so non-file-backed persisters are unaffected.
326 ///
327 /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
328 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
329 let _ = (session_id, json_line);
330 Ok(())
331 }
332}
333
334#[async_trait::async_trait]
335impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
336 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
337 (**self).save_runtime_session(session).await
338 }
339
340 async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
341 (**self).seed_runtime_activation(session).await
342 }
343
344 async fn record_permission_posture_activation(
345 &self,
346 session_id: &str,
347 expected_audit_revision: Option<u64>,
348 seed: &PermissionAuditSeed,
349 ) -> io::Result<Option<Session>> {
350 (**self)
351 .record_permission_posture_activation(session_id, expected_audit_revision, seed)
352 .await
353 }
354
355 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
356 (**self).save_runtime_control_plane(session).await
357 }
358
359 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
360 (**self).load_runtime_control_plane(session_id).await
361 }
362
363 async fn update_task_list_control_plane(
364 &self,
365 session_id: &str,
366 task_list: &TaskList,
367 version: &str,
368 ) -> io::Result<bool> {
369 (**self)
370 .update_task_list_control_plane(session_id, task_list, version)
371 .await
372 }
373
374 async fn update_task_list_control_plane_if_version(
375 &self,
376 session_id: &str,
377 expected_version: &str,
378 expected_task_list: &TaskList,
379 task_list: &TaskList,
380 version: &str,
381 ) -> io::Result<bool> {
382 (**self)
383 .update_task_list_control_plane_if_version(
384 session_id,
385 expected_version,
386 expected_task_list,
387 task_list,
388 version,
389 )
390 .await
391 }
392
393 async fn update_task_list_control_planes_if_version(
394 &self,
395 session_id: &str,
396 shared_session_id: &str,
397 expected_version: &str,
398 expected_task_list: &TaskList,
399 task_list: &TaskList,
400 version: &str,
401 ) -> io::Result<bool> {
402 (**self)
403 .update_task_list_control_planes_if_version(
404 session_id,
405 shared_session_id,
406 expected_version,
407 expected_task_list,
408 task_list,
409 version,
410 )
411 .await
412 }
413
414 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
415 (**self).checkpoint_runtime_session(session).await
416 }
417
418 async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
419 (**self).load_runtime_session(session_id).await
420 }
421
422 async fn clear_legacy_pending_messages(
423 &self,
424 session_id: &str,
425 expected: &[serde_json::Value],
426 ) -> io::Result<bool> {
427 (**self)
428 .clear_legacy_pending_messages(session_id, expected)
429 .await
430 }
431
432 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
433 (**self)
434 .append_token_usage_record(session_id, json_line)
435 .await
436 }
437}