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`, `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 changes.
170    async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
171        self.save_runtime_session(session).await
172    }
173
174    /// Load the representation paired with
175    /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
176    ///
177    /// Sidecar-capable implementations should return their message-free
178    /// control-plane snapshot. The default deliberately returns the full
179    /// runtime session: when the paired save also falls back to a full save,
180    /// retaining the transcript makes that fallback safe rather than replacing
181    /// durable messages with an empty sidecar-shaped snapshot.
182    async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
183        self.load_runtime_session(session_id).await
184    }
185
186    /// Atomically update only the shared Task list and its version.
187    ///
188    /// The default is safe for custom/legacy persistence: it loads the full
189    /// runtime session, changes only Task-owned fields, then uses the paired
190    /// control-plane save (which itself defaults to a full save). Returning
191    /// `false` means the implementation could not load the target; callers that
192    /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
193    /// behavior with an explicit full-load/full-save fallback.
194    ///
195    /// Implementations with per-session transactions should override this so
196    /// the load, narrow mutation and save share one critical section.
197    async fn update_task_list_control_plane(
198        &self,
199        session_id: &str,
200        task_list: &TaskList,
201        version: &str,
202    ) -> io::Result<bool> {
203        let Some(mut session) = self.load_runtime_session(session_id).await? else {
204            return Ok(false);
205        };
206        session.set_task_list(task_list.clone());
207        session.set_task_list_version_meta(version.to_string());
208        self.save_runtime_control_plane(&mut session).await?;
209        Ok(true)
210    }
211
212    /// Append-safe checkpoint used at the shared engine execute boundary.
213    ///
214    /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
215    /// preserve messages that were appended durably by a concurrent writer
216    /// after the runner loaded its snapshot.  Implementations that can provide
217    /// a per-session transaction should override this method and perform the
218    /// load/merge/save under one lock.  The default still reconciles against a
219    /// latest snapshot for lightweight/custom SDK persisters; the built-in
220    /// storage implementation supplies the atomic variant.
221    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
222        if let Some(durable) = self.load_runtime_session(&session.id).await? {
223            append_missing_runtime_messages(session, &durable);
224            merge_session_inbox_admission(session, &durable);
225        }
226        self.save_runtime_session(session).await
227    }
228
229    /// Load the latest runtime-visible session snapshot when the persistence
230    /// implementation can coordinate reads. Tools may update a repository-owned
231    /// clone while an agent loop holds its own live Session; the loop uses this
232    /// hook to merge narrowly-scoped tool side effects before its next save.
233    async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
234        Ok(None)
235    }
236
237    /// Clear the bounded compatibility queue iff it still equals the entries
238    /// that were durably copied into SessionInbox. Implementations with a
239    /// per-session transaction should override this method.
240    async fn clear_legacy_pending_messages(
241        &self,
242        session_id: &str,
243        expected: &[serde_json::Value],
244    ) -> io::Result<bool> {
245        let Some(mut latest) = self.load_runtime_session(session_id).await? else {
246            return Ok(false);
247        };
248        if latest.pending_injected_messages().as_deref() != Some(expected) {
249            return Ok(false);
250        }
251        latest.clear_pending_injected_messages();
252        self.save_runtime_session(&mut latest).await?;
253        Ok(true)
254    }
255
256    /// Append one JSON-line analysis record to the session's append-only
257    /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
258    /// a no-op so non-file-backed persisters are unaffected.
259    ///
260    /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
261    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
262        let _ = (session_id, json_line);
263        Ok(())
264    }
265}
266
267#[async_trait::async_trait]
268impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
269    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
270        (**self).save_runtime_session(session).await
271    }
272
273    async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
274        (**self).seed_runtime_activation(session).await
275    }
276
277    async fn record_permission_posture_activation(
278        &self,
279        session_id: &str,
280        expected_audit_revision: Option<u64>,
281        seed: &PermissionAuditSeed,
282    ) -> io::Result<Option<Session>> {
283        (**self)
284            .record_permission_posture_activation(session_id, expected_audit_revision, seed)
285            .await
286    }
287
288    async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
289        (**self).save_runtime_control_plane(session).await
290    }
291
292    async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
293        (**self).load_runtime_control_plane(session_id).await
294    }
295
296    async fn update_task_list_control_plane(
297        &self,
298        session_id: &str,
299        task_list: &TaskList,
300        version: &str,
301    ) -> io::Result<bool> {
302        (**self)
303            .update_task_list_control_plane(session_id, task_list, version)
304            .await
305    }
306
307    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
308        (**self).checkpoint_runtime_session(session).await
309    }
310
311    async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
312        (**self).load_runtime_session(session_id).await
313    }
314
315    async fn clear_legacy_pending_messages(
316        &self,
317        session_id: &str,
318        expected: &[serde_json::Value],
319    ) -> io::Result<bool> {
320        (**self)
321            .clear_legacy_pending_messages(session_id, expected)
322            .await
323    }
324
325    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
326        (**self)
327            .append_token_usage_record(session_id, json_line)
328            .await
329    }
330}