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 // Provider-native groups are message-anchored and append-only as well. A
32 // concurrent durable prefix must not be erased by a stale runner save, and
33 // a runner's newly completed group must remain paired with its new message.
34 session.merge_provider_transcript_from_durable(durable);
35 appended
36}
37
38/// Merge the durable SessionInbox admitted-id cursor into a writer snapshot.
39///
40/// Runtime writers can hold a session clone from before another run admitted
41/// an inbox message. No later full save may erase that durable dedupe state.
42pub fn merge_session_inbox_admission(session: &mut Session, durable: &Session) {
43 let Some(durable_state) = durable.session_inbox_admission().cloned() else {
44 return;
45 };
46 session
47 .session_inbox_admission_mut()
48 .merge_from(&durable_state);
49}
50
51/// Restore durable provider messages identified by their typed
52/// `metadata.session_message` marker into a stale writer without preserving
53/// unrelated durable suffixes. The bounded cursor is only a fast recent index;
54/// the transcript marker is the unbounded source of truth after cursor
55/// eviction.
56///
57/// Insertion follows durable transcript neighbors so an admitted user/runtime
58/// message remains ahead of any later assistant output held by the stale
59/// runner. This is narrower than [`append_missing_runtime_messages`], retaining
60/// the historical shrink semantics for unrelated concurrent messages while
61/// making a cursor/tombstone incapable of outliving its transcript entry.
62pub fn restore_missing_admitted_inbox_messages(session: &mut Session, durable: &Session) -> usize {
63 let admission = durable.session_inbox_admission();
64 let mut restored = 0;
65 for (durable_index, message) in durable.messages.iter().enumerate() {
66 let typed_marker = message
67 .metadata
68 .as_ref()
69 .and_then(|metadata| metadata.get("session_message"))
70 .is_some_and(|marker| {
71 marker.get("id").and_then(serde_json::Value::as_str) == Some(message.id.as_str())
72 && marker
73 .get("target_session_id")
74 .and_then(serde_json::Value::as_str)
75 == Some(durable.id.as_str())
76 && crate::SessionMessageId::parse(message.id.clone()).is_ok()
77 });
78 let recent_cursor = admission.is_some_and(|state| state.contains_str(&message.id));
79 if !(typed_marker || recent_cursor)
80 || session
81 .messages
82 .iter()
83 .any(|current| current.id == message.id)
84 {
85 continue;
86 }
87
88 let insertion = durable.messages[..durable_index]
89 .iter()
90 .rev()
91 .find_map(|predecessor| {
92 session
93 .messages
94 .iter()
95 .position(|current| current.id == predecessor.id)
96 .map(|index| index + 1)
97 })
98 .or_else(|| {
99 durable.messages[durable_index + 1..]
100 .iter()
101 .find_map(|successor| {
102 session
103 .messages
104 .iter()
105 .position(|current| current.id == successor.id)
106 })
107 })
108 .unwrap_or(session.messages.len());
109 session.messages.insert(insertion, message.clone());
110 restored += 1;
111 }
112 restored
113}
114
115/// Port for runtime (non-authoritative) session persistence.
116///
117/// Implementors must:
118/// - Serialize concurrent saves per session ID.
119/// - Merge on-disk authoritative metadata (`title`, `title_generated`, `pinned`, `title_version`,
120/// `metadata_version`) before writing, so UI edits are never clobbered.
121#[async_trait::async_trait]
122pub trait RuntimeSessionPersistence: Send + Sync {
123 /// Persist the session, merging any newer authoritative metadata from disk.
124 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()>;
125
126 /// Authoritatively seed one validated actor activation.
127 ///
128 /// Unlike an ordinary runtime save, the incoming RunSpec posture and its
129 /// complete audit record must replace any posture left by a previous warm
130 /// activation. Implementations must still preserve durable SessionInbox
131 /// admission/transcript proof and serialize the operation per session.
132 ///
133 /// There is no safe generic implementation through
134 /// [`Self::save_runtime_session`]: that primitive is explicitly allowed to
135 /// adopt a newer disk posture, which would make warm workers sticky across
136 /// runs. Custom persisters therefore fail closed until they implement this
137 /// authority boundary deliberately.
138 async fn seed_runtime_activation(&self, _session: &mut Session) -> io::Result<()> {
139 Err(io::Error::new(
140 io::ErrorKind::Unsupported,
141 "runtime persistence does not support authoritative activation seeding",
142 ))
143 }
144
145 /// Atomically persist a worker-declared executor mapping for the current
146 /// host-authoritative permission posture.
147 ///
148 /// The caller supplies the audit revision it observed before dispatch.
149 /// Implementations must load and compare that revision while holding the
150 /// per-session lock, reject a concurrent posture update, and allocate a new
151 /// host revision/timestamp themselves. Remote audit clocks are never an
152 /// authority at this boundary.
153 async fn record_permission_posture_activation(
154 &self,
155 _session_id: &str,
156 _expected_audit_revision: Option<u64>,
157 _seed: &PermissionAuditSeed,
158 ) -> io::Result<Option<Session>> {
159 Err(io::Error::new(
160 io::ErrorKind::Unsupported,
161 "runtime persistence does not support atomic permission posture activation",
162 ))
163 }
164
165 /// Persist only the runtime control-plane for a session.
166 ///
167 /// Task lists and other runtime metadata belong to the control-plane and do
168 /// not require rewriting the potentially large message transcript. Built-in
169 /// persistence implementations with a runtime sidecar should override this
170 /// operation with their sidecar-only path. Custom/legacy implementations
171 /// remain source-compatible and safely fall back to the full runtime save.
172 ///
173 /// Callers must not rely on this operation to persist message or
174 /// `model_context_state` changes. The durable ledger is checkpoint-owned;
175 /// sidecar implementations must preserve its latest committed value while
176 /// applying the caller's narrow control-plane mutation.
177 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
178 self.save_runtime_session(session).await
179 }
180
181 /// Load the representation paired with
182 /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
183 ///
184 /// Sidecar-capable implementations should return their message-free
185 /// control-plane snapshot. The default deliberately returns the full
186 /// runtime session: when the paired save also falls back to a full save,
187 /// retaining the transcript makes that fallback safe rather than replacing
188 /// durable messages with an empty sidecar-shaped snapshot.
189 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
190 self.load_runtime_session(session_id).await
191 }
192
193 /// Atomically update only the shared Task list and its version.
194 ///
195 /// The default is safe for custom/legacy persistence: it loads the full
196 /// runtime session, changes only Task-owned fields, then uses the paired
197 /// control-plane save (which itself defaults to a full save). Returning
198 /// `false` means the implementation could not load the target; callers that
199 /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
200 /// behavior with an explicit full-load/full-save fallback.
201 ///
202 /// Implementations with per-session transactions should override this so
203 /// the load, narrow mutation and save share one critical section.
204 async fn update_task_list_control_plane(
205 &self,
206 session_id: &str,
207 task_list: &TaskList,
208 version: &str,
209 ) -> io::Result<bool> {
210 let Some(mut session) = self.load_runtime_session(session_id).await? else {
211 return Ok(false);
212 };
213 session.set_task_list(task_list.clone());
214 session.set_task_list_version_meta(version.to_string());
215 self.save_runtime_control_plane(&mut session).await?;
216 Ok(true)
217 }
218
219 /// Atomically update Task-owned control-plane fields only when the durable
220 /// Task generation and exact list still match the expected snapshot.
221 ///
222 /// `false` covers an unsupported atomic compare-and-patch, a missing target,
223 /// or a version conflict. Callers must treat it as a stale write and must
224 /// not publish their staged Task state. The default fails closed because a
225 /// load followed by a separately locked save is not an atomic CAS.
226 async fn update_task_list_control_plane_if_version(
227 &self,
228 session_id: &str,
229 expected_version: &str,
230 expected_task_list: &TaskList,
231 task_list: &TaskList,
232 version: &str,
233 ) -> io::Result<bool> {
234 let _ = (
235 session_id,
236 expected_version,
237 expected_task_list,
238 task_list,
239 version,
240 );
241 Ok(false)
242 }
243
244 /// Recoverably compare-and-patch the executing session and its shared root.
245 /// Implementations must validate both generations before either target is
246 /// written and may return `Ok(true)` only after both Task generations are
247 /// durable with no undo record that could later revert them. An error after
248 /// one physical write must restore both originals before returning or retain
249 /// durable recovery state and fail subsequent paired access closed until
250 /// recovery completes. Root-session callers pass the same id twice and
251 /// receive the single-target CAS semantics above.
252 async fn update_task_list_control_planes_if_version(
253 &self,
254 session_id: &str,
255 shared_session_id: &str,
256 expected_version: &str,
257 expected_task_list: &TaskList,
258 task_list: &TaskList,
259 version: &str,
260 ) -> io::Result<bool> {
261 if session_id == shared_session_id {
262 return self
263 .update_task_list_control_plane_if_version(
264 session_id,
265 expected_version,
266 expected_task_list,
267 task_list,
268 version,
269 )
270 .await;
271 }
272 let _ = (
273 session_id,
274 shared_session_id,
275 expected_version,
276 expected_task_list,
277 task_list,
278 version,
279 );
280 Ok(false)
281 }
282
283 /// Append-safe checkpoint used at the shared engine execute boundary.
284 ///
285 /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
286 /// preserve messages that were appended durably by a concurrent writer
287 /// after the runner loaded its snapshot. Implementations that can provide
288 /// a per-session transaction should override this method and perform the
289 /// load/merge/save under one lock. The default still reconciles against a
290 /// latest snapshot for lightweight/custom SDK persisters; the built-in
291 /// storage implementation supplies the atomic variant.
292 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
293 if let Some(durable) = self.load_runtime_session(&session.id).await? {
294 append_missing_runtime_messages(session, &durable);
295 merge_session_inbox_admission(session, &durable);
296 }
297 self.save_runtime_session(session).await
298 }
299
300 /// Load the latest runtime-visible session snapshot when the persistence
301 /// implementation can coordinate reads. Tools may update a repository-owned
302 /// clone while an agent loop holds its own live Session; the loop uses this
303 /// hook to merge narrowly-scoped tool side effects before its next save.
304 async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
305 Ok(None)
306 }
307
308 /// Clear the bounded compatibility queue iff it still equals the entries
309 /// that were durably copied into SessionInbox. Implementations with a
310 /// per-session transaction should override this method.
311 async fn clear_legacy_pending_messages(
312 &self,
313 session_id: &str,
314 expected: &[serde_json::Value],
315 ) -> io::Result<bool> {
316 let Some(mut latest) = self.load_runtime_session(session_id).await? else {
317 return Ok(false);
318 };
319 if latest.pending_injected_messages().as_deref() != Some(expected) {
320 return Ok(false);
321 }
322 latest.clear_pending_injected_messages();
323 self.save_runtime_session(&mut latest).await?;
324 Ok(true)
325 }
326
327 /// Append one JSON-line analysis record to the session's append-only
328 /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
329 /// a no-op so non-file-backed persisters are unaffected.
330 ///
331 /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
332 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
333 let _ = (session_id, json_line);
334 Ok(())
335 }
336}
337
338#[async_trait::async_trait]
339impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
340 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
341 (**self).save_runtime_session(session).await
342 }
343
344 async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
345 (**self).seed_runtime_activation(session).await
346 }
347
348 async fn record_permission_posture_activation(
349 &self,
350 session_id: &str,
351 expected_audit_revision: Option<u64>,
352 seed: &PermissionAuditSeed,
353 ) -> io::Result<Option<Session>> {
354 (**self)
355 .record_permission_posture_activation(session_id, expected_audit_revision, seed)
356 .await
357 }
358
359 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
360 (**self).save_runtime_control_plane(session).await
361 }
362
363 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
364 (**self).load_runtime_control_plane(session_id).await
365 }
366
367 async fn update_task_list_control_plane(
368 &self,
369 session_id: &str,
370 task_list: &TaskList,
371 version: &str,
372 ) -> io::Result<bool> {
373 (**self)
374 .update_task_list_control_plane(session_id, task_list, version)
375 .await
376 }
377
378 async fn update_task_list_control_plane_if_version(
379 &self,
380 session_id: &str,
381 expected_version: &str,
382 expected_task_list: &TaskList,
383 task_list: &TaskList,
384 version: &str,
385 ) -> io::Result<bool> {
386 (**self)
387 .update_task_list_control_plane_if_version(
388 session_id,
389 expected_version,
390 expected_task_list,
391 task_list,
392 version,
393 )
394 .await
395 }
396
397 async fn update_task_list_control_planes_if_version(
398 &self,
399 session_id: &str,
400 shared_session_id: &str,
401 expected_version: &str,
402 expected_task_list: &TaskList,
403 task_list: &TaskList,
404 version: &str,
405 ) -> io::Result<bool> {
406 (**self)
407 .update_task_list_control_planes_if_version(
408 session_id,
409 shared_session_id,
410 expected_version,
411 expected_task_list,
412 task_list,
413 version,
414 )
415 .await
416 }
417
418 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
419 (**self).checkpoint_runtime_session(session).await
420 }
421
422 async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
423 (**self).load_runtime_session(session_id).await
424 }
425
426 async fn clear_legacy_pending_messages(
427 &self,
428 session_id: &str,
429 expected: &[serde_json::Value],
430 ) -> io::Result<bool> {
431 (**self)
432 .clear_legacy_pending_messages(session_id, expected)
433 .await
434 }
435
436 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
437 (**self)
438 .append_token_usage_record(session_id, json_line)
439 .await
440 }
441}