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/// 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}