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/// Result of the retrieval-window-specific execute-boundary checkpoint.
9///
10/// `Rebased` means persistence observed a newer durable transcript, made no
11/// write, and replaced the caller's staged value with a clean reconciled base
12/// that must be planned and prepared again before dispatch.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RetrievalWindowCheckpointOutcome {
15 Committed,
16 Rebased,
17}
18
19/// Merge messages from a live runner snapshot into an already-durable
20/// transcript without ever removing or rewriting a durable message.
21///
22/// Runtime sessions are append-oriented, and every newly-created message has a
23/// stable id. A runner may nevertheless be holding a snapshot that predates a
24/// concurrent append (for example, an injected child-completion message). A
25/// terminal/error checkpoint must not full-save that stale snapshot: doing so
26/// would shrink the transcript. Keep the durable ordering and append only the
27/// live messages whose ids are not durable yet.
28pub fn append_missing_runtime_messages(session: &mut Session, durable: &Session) -> usize {
29 let mut seen = durable
30 .messages
31 .iter()
32 .map(|message| message.id.clone())
33 .collect::<std::collections::HashSet<_>>();
34 let missing = session
35 .messages
36 .iter()
37 .filter(|message| seen.insert(message.id.clone()))
38 .cloned()
39 .collect::<Vec<_>>();
40 let appended = missing.len();
41 session.messages = durable.messages.iter().cloned().chain(missing).collect();
42 // Provider-native groups are message-anchored and append-only as well. A
43 // concurrent durable prefix must not be erased by a stale runner save, and
44 // a runner's newly completed group must remain paired with its new message.
45 session.merge_provider_transcript_from_durable(durable);
46 appended
47}
48
49/// Merge the durable SessionInbox admitted-id cursor into a writer snapshot.
50///
51/// Runtime writers can hold a session clone from before another run admitted
52/// an inbox message. No later full save may erase that durable dedupe state.
53pub fn merge_session_inbox_admission(session: &mut Session, durable: &Session) {
54 let Some(durable_state) = durable.session_inbox_admission().cloned() else {
55 return;
56 };
57 session
58 .session_inbox_admission_mut()
59 .merge_from(&durable_state);
60}
61
62/// Restore durable provider messages identified by their typed
63/// `metadata.session_message` marker into a stale writer without preserving
64/// unrelated durable suffixes. The bounded cursor is only a fast recent index;
65/// the transcript marker is the unbounded source of truth after cursor
66/// eviction.
67///
68/// Insertion follows durable transcript neighbors so an admitted user/runtime
69/// message remains ahead of any later assistant output held by the stale
70/// runner. This is narrower than [`append_missing_runtime_messages`], retaining
71/// the historical shrink semantics for unrelated concurrent messages while
72/// making a cursor/tombstone incapable of outliving its transcript entry.
73pub fn restore_missing_admitted_inbox_messages(session: &mut Session, durable: &Session) -> usize {
74 let admission = durable.session_inbox_admission();
75 let mut restored = 0;
76 for (durable_index, message) in durable.messages.iter().enumerate() {
77 let typed_marker = message
78 .metadata
79 .as_ref()
80 .and_then(|metadata| metadata.get("session_message"))
81 .is_some_and(|marker| {
82 marker.get("id").and_then(serde_json::Value::as_str) == Some(message.id.as_str())
83 && marker
84 .get("target_session_id")
85 .and_then(serde_json::Value::as_str)
86 == Some(durable.id.as_str())
87 && crate::SessionMessageId::parse(message.id.clone()).is_ok()
88 });
89 let recent_cursor = admission.is_some_and(|state| state.contains_str(&message.id));
90 if !(typed_marker || recent_cursor)
91 || session
92 .messages
93 .iter()
94 .any(|current| current.id == message.id)
95 {
96 continue;
97 }
98
99 let insertion = durable.messages[..durable_index]
100 .iter()
101 .rev()
102 .find_map(|predecessor| {
103 session
104 .messages
105 .iter()
106 .position(|current| current.id == predecessor.id)
107 .map(|index| index + 1)
108 })
109 .or_else(|| {
110 durable.messages[durable_index + 1..]
111 .iter()
112 .find_map(|successor| {
113 session
114 .messages
115 .iter()
116 .position(|current| current.id == successor.id)
117 })
118 })
119 .unwrap_or(session.messages.len());
120 session.messages.insert(insertion, message.clone());
121 restored += 1;
122 }
123 restored
124}
125
126/// Port for runtime (non-authoritative) session persistence.
127///
128/// Implementors must:
129/// - Serialize concurrent saves per session ID.
130/// - Merge on-disk authoritative metadata (`title`, `title_generated`, `pinned`, `title_version`,
131/// `metadata_version`) before writing, so UI edits are never clobbered.
132#[async_trait::async_trait]
133pub trait RuntimeSessionPersistence: Send + Sync {
134 /// Persist the session, merging any newer authoritative metadata from disk.
135 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()>;
136
137 /// Authoritatively seed one validated actor activation.
138 ///
139 /// Unlike an ordinary runtime save, the incoming RunSpec posture and its
140 /// complete audit record must replace any posture left by a previous warm
141 /// activation. Implementations must still preserve durable SessionInbox
142 /// admission/transcript proof and serialize the operation per session.
143 ///
144 /// There is no safe generic implementation through
145 /// [`Self::save_runtime_session`]: that primitive is explicitly allowed to
146 /// adopt a newer disk posture, which would make warm workers sticky across
147 /// runs. Custom persisters therefore fail closed until they implement this
148 /// authority boundary deliberately.
149 async fn seed_runtime_activation(&self, _session: &mut Session) -> io::Result<()> {
150 Err(io::Error::new(
151 io::ErrorKind::Unsupported,
152 "runtime persistence does not support authoritative activation seeding",
153 ))
154 }
155
156 /// Atomically persist a worker-declared executor mapping for the current
157 /// host-authoritative permission posture.
158 ///
159 /// The caller supplies the audit revision it observed before dispatch.
160 /// Implementations must load and compare that revision while holding the
161 /// per-session lock, reject a concurrent posture update, and allocate a new
162 /// host revision/timestamp themselves. Remote audit clocks are never an
163 /// authority at this boundary.
164 async fn record_permission_posture_activation(
165 &self,
166 _session_id: &str,
167 _expected_audit_revision: Option<u64>,
168 _seed: &PermissionAuditSeed,
169 ) -> io::Result<Option<Session>> {
170 Err(io::Error::new(
171 io::ErrorKind::Unsupported,
172 "runtime persistence does not support atomic permission posture activation",
173 ))
174 }
175
176 /// Persist only the runtime control-plane for a session.
177 ///
178 /// Task lists and other runtime metadata belong to the control-plane and do
179 /// not require rewriting the potentially large message transcript. Built-in
180 /// persistence implementations with a runtime sidecar should override this
181 /// operation with their sidecar-only path. Custom/legacy implementations
182 /// remain source-compatible and safely fall back to the full runtime save.
183 ///
184 /// Callers must not rely on this operation to persist message or
185 /// `model_context_state` changes. The durable ledger is checkpoint-owned;
186 /// sidecar implementations must preserve its latest committed value while
187 /// applying the caller's narrow control-plane mutation.
188 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
189 self.save_runtime_session(session).await
190 }
191
192 /// Load the representation paired with
193 /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
194 ///
195 /// Sidecar-capable implementations should return their message-free
196 /// control-plane snapshot. The default deliberately returns the full
197 /// runtime session: when the paired save also falls back to a full save,
198 /// retaining the transcript makes that fallback safe rather than replacing
199 /// durable messages with an empty sidecar-shaped snapshot.
200 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
201 self.load_runtime_session(session_id).await
202 }
203
204 /// Atomically update only the shared Task list and its version.
205 ///
206 /// The default is safe for custom/legacy persistence: it loads the full
207 /// runtime session, changes only Task-owned fields, then uses the paired
208 /// control-plane save (which itself defaults to a full save). Returning
209 /// `false` means the implementation could not load the target; callers that
210 /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
211 /// behavior with an explicit full-load/full-save fallback.
212 ///
213 /// Implementations with per-session transactions should override this so
214 /// the load, narrow mutation and save share one critical section.
215 async fn update_task_list_control_plane(
216 &self,
217 session_id: &str,
218 task_list: &TaskList,
219 version: &str,
220 ) -> io::Result<bool> {
221 let Some(mut session) = self.load_runtime_session(session_id).await? else {
222 return Ok(false);
223 };
224 session.set_task_list(task_list.clone());
225 session.set_task_list_version_meta(version.to_string());
226 self.save_runtime_control_plane(&mut session).await?;
227 Ok(true)
228 }
229
230 /// Atomically update Task-owned control-plane fields only when the durable
231 /// Task generation and exact list still match the expected snapshot.
232 ///
233 /// `false` covers an unsupported atomic compare-and-patch, a missing target,
234 /// or a version conflict. Callers must treat it as a stale write and must
235 /// not publish their staged Task state. The default fails closed because a
236 /// load followed by a separately locked save is not an atomic CAS.
237 async fn update_task_list_control_plane_if_version(
238 &self,
239 session_id: &str,
240 expected_version: &str,
241 expected_task_list: &TaskList,
242 task_list: &TaskList,
243 version: &str,
244 ) -> io::Result<bool> {
245 let _ = (
246 session_id,
247 expected_version,
248 expected_task_list,
249 task_list,
250 version,
251 );
252 Ok(false)
253 }
254
255 /// Recoverably compare-and-patch the executing session and its shared root.
256 /// Implementations must validate both generations before either target is
257 /// written and may return `Ok(true)` only after both Task generations are
258 /// durable with no undo record that could later revert them. An error after
259 /// one physical write must restore both originals before returning or retain
260 /// durable recovery state and fail subsequent paired access closed until
261 /// recovery completes. Root-session callers pass the same id twice and
262 /// receive the single-target CAS semantics above.
263 async fn update_task_list_control_planes_if_version(
264 &self,
265 session_id: &str,
266 shared_session_id: &str,
267 expected_version: &str,
268 expected_task_list: &TaskList,
269 task_list: &TaskList,
270 version: &str,
271 ) -> io::Result<bool> {
272 if session_id == shared_session_id {
273 return self
274 .update_task_list_control_plane_if_version(
275 session_id,
276 expected_version,
277 expected_task_list,
278 task_list,
279 version,
280 )
281 .await;
282 }
283 let _ = (
284 session_id,
285 shared_session_id,
286 expected_version,
287 expected_task_list,
288 task_list,
289 version,
290 );
291 Ok(false)
292 }
293
294 /// Append-safe checkpoint used at the shared engine execute boundary.
295 ///
296 /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
297 /// preserve messages that were appended durably by a concurrent writer
298 /// after the runner loaded its snapshot. Implementations that can provide
299 /// a per-session transaction should override this method and perform the
300 /// load/merge/save under one lock. The default still reconciles against a
301 /// latest snapshot for lightweight/custom SDK persisters; the built-in
302 /// storage implementation supplies the atomic variant.
303 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
304 if let Some(durable) = self.load_runtime_session(&session.id).await? {
305 append_missing_runtime_messages(session, &durable);
306 merge_session_inbox_admission(session, &durable);
307 }
308 self.save_runtime_session(session).await
309 }
310
311 /// Atomically commit a staged retrieval-window transcript rewrite.
312 ///
313 /// `expected_base` is the exact pre-archive Session used for planning.
314 /// Implementations must compare it with the latest durable transcript while
315 /// holding their per-session serialization lock. If a concurrent append or
316 /// rewrite is present, they must perform no save, rebase `staged` onto that
317 /// durable snapshot, and return [`RetrievalWindowCheckpointOutcome::Rebased`].
318 /// Otherwise they must preserve the staged message archive flags and commit
319 /// them with the compression event and model-context reset.
320 ///
321 /// There is no safe fallback through the ordinary append-only checkpoint,
322 /// because that path deliberately restores durable message clones and would
323 /// erase the new archive flags. Custom persisters therefore fail closed
324 /// until they implement this boundary explicitly.
325 async fn checkpoint_retrieval_window(
326 &self,
327 _expected_base: &Session,
328 _staged: &mut Session,
329 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
330 Err(io::Error::new(
331 io::ErrorKind::Unsupported,
332 "runtime persistence does not support retrieval-window checkpoints",
333 ))
334 }
335
336 /// Atomically commit a provider-visible System-prompt rewrite.
337 ///
338 /// This is deliberately separate from both the append-safe runtime
339 /// checkpoint (which must restore durable message content) and the
340 /// retrieval-window archive checkpoint (which requires a new archive
341 /// event). Implementations compare `expected_base` under their per-session
342 /// lock, return `Rebased` without writing on conflict, and accept only the
343 /// bounded prompt rewrite plus its provider/model-context reset.
344 async fn checkpoint_prompt_rewrite(
345 &self,
346 _expected_base: &Session,
347 _staged: &mut Session,
348 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
349 Err(io::Error::new(
350 io::ErrorKind::Unsupported,
351 "runtime persistence does not support prompt-rewrite checkpoints",
352 ))
353 }
354
355 /// Atomically commit one permanently rejected `archive_context` result.
356 ///
357 /// This boundary permits only the correlated Tool result rewrite, the
358 /// bounded consumed/rejection metadata, and the provider/model-context
359 /// reset required to make that rewrite visible. Implementations compare
360 /// `expected_base` under their per-session lock and return `Rebased`
361 /// without writing when the durable transcript changed concurrently.
362 async fn checkpoint_manual_archive_rejection(
363 &self,
364 _expected_base: &Session,
365 _staged: &mut Session,
366 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
367 Err(io::Error::new(
368 io::ErrorKind::Unsupported,
369 "runtime persistence does not support manual archive-rejection checkpoints",
370 ))
371 }
372
373 /// Atomically consume one successful no-op `archive_context` request.
374 ///
375 /// This boundary permits only the correlated consumed-occurrence marker.
376 /// Implementations compare `expected_base` under their per-session lock
377 /// and return `Rebased` without writing when any durable transcript,
378 /// metadata, runtime metadata, or execution-profile field changed. The
379 /// caller must then restage the marker from the returned durable snapshot.
380 async fn checkpoint_manual_archive_consumption(
381 &self,
382 _expected_base: &Session,
383 _staged: &mut Session,
384 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
385 Err(io::Error::new(
386 io::ErrorKind::Unsupported,
387 "runtime persistence does not support manual archive-consumption checkpoints",
388 ))
389 }
390
391 /// Load the latest runtime-visible session snapshot when the persistence
392 /// implementation can coordinate reads. Tools may update a repository-owned
393 /// clone while an agent loop holds its own live Session; the loop uses this
394 /// hook to merge narrowly-scoped tool side effects before its next save.
395 async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
396 Ok(None)
397 }
398
399 /// Clear the bounded compatibility queue iff it still equals the entries
400 /// that were durably copied into SessionInbox. Implementations with a
401 /// per-session transaction should override this method.
402 async fn clear_legacy_pending_messages(
403 &self,
404 session_id: &str,
405 expected: &[serde_json::Value],
406 ) -> io::Result<bool> {
407 let Some(mut latest) = self.load_runtime_session(session_id).await? else {
408 return Ok(false);
409 };
410 if latest.pending_injected_messages().as_deref() != Some(expected) {
411 return Ok(false);
412 }
413 latest.clear_pending_injected_messages();
414 self.save_runtime_session(&mut latest).await?;
415 Ok(true)
416 }
417
418 /// Append one JSON-line analysis record to the session's append-only
419 /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
420 /// a no-op so non-file-backed persisters are unaffected.
421 ///
422 /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
423 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
424 let _ = (session_id, json_line);
425 Ok(())
426 }
427}
428
429#[async_trait::async_trait]
430impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
431 async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
432 (**self).save_runtime_session(session).await
433 }
434
435 async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
436 (**self).seed_runtime_activation(session).await
437 }
438
439 async fn record_permission_posture_activation(
440 &self,
441 session_id: &str,
442 expected_audit_revision: Option<u64>,
443 seed: &PermissionAuditSeed,
444 ) -> io::Result<Option<Session>> {
445 (**self)
446 .record_permission_posture_activation(session_id, expected_audit_revision, seed)
447 .await
448 }
449
450 async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
451 (**self).save_runtime_control_plane(session).await
452 }
453
454 async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
455 (**self).load_runtime_control_plane(session_id).await
456 }
457
458 async fn update_task_list_control_plane(
459 &self,
460 session_id: &str,
461 task_list: &TaskList,
462 version: &str,
463 ) -> io::Result<bool> {
464 (**self)
465 .update_task_list_control_plane(session_id, task_list, version)
466 .await
467 }
468
469 async fn update_task_list_control_plane_if_version(
470 &self,
471 session_id: &str,
472 expected_version: &str,
473 expected_task_list: &TaskList,
474 task_list: &TaskList,
475 version: &str,
476 ) -> io::Result<bool> {
477 (**self)
478 .update_task_list_control_plane_if_version(
479 session_id,
480 expected_version,
481 expected_task_list,
482 task_list,
483 version,
484 )
485 .await
486 }
487
488 async fn update_task_list_control_planes_if_version(
489 &self,
490 session_id: &str,
491 shared_session_id: &str,
492 expected_version: &str,
493 expected_task_list: &TaskList,
494 task_list: &TaskList,
495 version: &str,
496 ) -> io::Result<bool> {
497 (**self)
498 .update_task_list_control_planes_if_version(
499 session_id,
500 shared_session_id,
501 expected_version,
502 expected_task_list,
503 task_list,
504 version,
505 )
506 .await
507 }
508
509 async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
510 (**self).checkpoint_runtime_session(session).await
511 }
512
513 async fn checkpoint_retrieval_window(
514 &self,
515 expected_base: &Session,
516 staged: &mut Session,
517 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
518 (**self)
519 .checkpoint_retrieval_window(expected_base, staged)
520 .await
521 }
522
523 async fn checkpoint_prompt_rewrite(
524 &self,
525 expected_base: &Session,
526 staged: &mut Session,
527 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
528 (**self)
529 .checkpoint_prompt_rewrite(expected_base, staged)
530 .await
531 }
532
533 async fn checkpoint_manual_archive_rejection(
534 &self,
535 expected_base: &Session,
536 staged: &mut Session,
537 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
538 (**self)
539 .checkpoint_manual_archive_rejection(expected_base, staged)
540 .await
541 }
542
543 async fn checkpoint_manual_archive_consumption(
544 &self,
545 expected_base: &Session,
546 staged: &mut Session,
547 ) -> io::Result<RetrievalWindowCheckpointOutcome> {
548 (**self)
549 .checkpoint_manual_archive_consumption(expected_base, staged)
550 .await
551 }
552
553 async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
554 (**self).load_runtime_session(session_id).await
555 }
556
557 async fn clear_legacy_pending_messages(
558 &self,
559 session_id: &str,
560 expected: &[serde_json::Value],
561 ) -> io::Result<bool> {
562 (**self)
563 .clear_legacy_pending_messages(session_id, expected)
564 .await
565 }
566
567 async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
568 (**self)
569 .append_token_usage_record(session_id, json_line)
570 .await
571 }
572}