Skip to main content

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}