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