bamboo-domain 2026.8.24

Domain models and shared types for the Bamboo agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
use std::io;
use std::sync::Arc;

use crate::session::task::TaskList;
use crate::session::types::Session;
use crate::session::PermissionAuditSeed;

/// Merge messages from a live runner snapshot into an already-durable
/// transcript without ever removing or rewriting a durable message.
///
/// Runtime sessions are append-oriented, and every newly-created message has a
/// stable id.  A runner may nevertheless be holding a snapshot that predates a
/// concurrent append (for example, an injected child-completion message).  A
/// terminal/error checkpoint must not full-save that stale snapshot: doing so
/// would shrink the transcript.  Keep the durable ordering and append only the
/// live messages whose ids are not durable yet.
pub fn append_missing_runtime_messages(session: &mut Session, durable: &Session) -> usize {
    let mut seen = durable
        .messages
        .iter()
        .map(|message| message.id.clone())
        .collect::<std::collections::HashSet<_>>();
    let missing = session
        .messages
        .iter()
        .filter(|message| seen.insert(message.id.clone()))
        .cloned()
        .collect::<Vec<_>>();
    let appended = missing.len();
    session.messages = durable.messages.iter().cloned().chain(missing).collect();
    appended
}

/// Merge the durable SessionInbox admitted-id cursor into a writer snapshot.
///
/// Runtime writers can hold a session clone from before another run admitted
/// an inbox message. No later full save may erase that durable dedupe state.
pub fn merge_session_inbox_admission(session: &mut Session, durable: &Session) {
    let Some(durable_state) = durable.session_inbox_admission().cloned() else {
        return;
    };
    session
        .session_inbox_admission_mut()
        .merge_from(&durable_state);
}

/// Restore durable provider messages identified by their typed
/// `metadata.session_message` marker into a stale writer without preserving
/// unrelated durable suffixes. The bounded cursor is only a fast recent index;
/// the transcript marker is the unbounded source of truth after cursor
/// eviction.
///
/// Insertion follows durable transcript neighbors so an admitted user/runtime
/// message remains ahead of any later assistant output held by the stale
/// runner. This is narrower than [`append_missing_runtime_messages`], retaining
/// the historical shrink semantics for unrelated concurrent messages while
/// making a cursor/tombstone incapable of outliving its transcript entry.
pub fn restore_missing_admitted_inbox_messages(session: &mut Session, durable: &Session) -> usize {
    let admission = durable.session_inbox_admission();
    let mut restored = 0;
    for (durable_index, message) in durable.messages.iter().enumerate() {
        let typed_marker = message
            .metadata
            .as_ref()
            .and_then(|metadata| metadata.get("session_message"))
            .is_some_and(|marker| {
                marker.get("id").and_then(serde_json::Value::as_str) == Some(message.id.as_str())
                    && marker
                        .get("target_session_id")
                        .and_then(serde_json::Value::as_str)
                        == Some(durable.id.as_str())
                    && crate::SessionMessageId::parse(message.id.clone()).is_ok()
            });
        let recent_cursor = admission.is_some_and(|state| state.contains_str(&message.id));
        if !(typed_marker || recent_cursor)
            || session
                .messages
                .iter()
                .any(|current| current.id == message.id)
        {
            continue;
        }

        let insertion = durable.messages[..durable_index]
            .iter()
            .rev()
            .find_map(|predecessor| {
                session
                    .messages
                    .iter()
                    .position(|current| current.id == predecessor.id)
                    .map(|index| index + 1)
            })
            .or_else(|| {
                durable.messages[durable_index + 1..]
                    .iter()
                    .find_map(|successor| {
                        session
                            .messages
                            .iter()
                            .position(|current| current.id == successor.id)
                    })
            })
            .unwrap_or(session.messages.len());
        session.messages.insert(insertion, message.clone());
        restored += 1;
    }
    restored
}

/// Port for runtime (non-authoritative) session persistence.
///
/// Implementors must:
/// - Serialize concurrent saves per session ID.
/// - Merge on-disk authoritative metadata (`title`, `title_generated`, `pinned`, `title_version`,
///   `metadata_version`) before writing, so UI edits are never clobbered.
#[async_trait::async_trait]
pub trait RuntimeSessionPersistence: Send + Sync {
    /// Persist the session, merging any newer authoritative metadata from disk.
    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()>;

    /// Authoritatively seed one validated actor activation.
    ///
    /// Unlike an ordinary runtime save, the incoming RunSpec posture and its
    /// complete audit record must replace any posture left by a previous warm
    /// activation. Implementations must still preserve durable SessionInbox
    /// admission/transcript proof and serialize the operation per session.
    ///
    /// There is no safe generic implementation through
    /// [`Self::save_runtime_session`]: that primitive is explicitly allowed to
    /// adopt a newer disk posture, which would make warm workers sticky across
    /// runs. Custom persisters therefore fail closed until they implement this
    /// authority boundary deliberately.
    async fn seed_runtime_activation(&self, _session: &mut Session) -> io::Result<()> {
        Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "runtime persistence does not support authoritative activation seeding",
        ))
    }

    /// Atomically persist a worker-declared executor mapping for the current
    /// host-authoritative permission posture.
    ///
    /// The caller supplies the audit revision it observed before dispatch.
    /// Implementations must load and compare that revision while holding the
    /// per-session lock, reject a concurrent posture update, and allocate a new
    /// host revision/timestamp themselves. Remote audit clocks are never an
    /// authority at this boundary.
    async fn record_permission_posture_activation(
        &self,
        _session_id: &str,
        _expected_audit_revision: Option<u64>,
        _seed: &PermissionAuditSeed,
    ) -> io::Result<Option<Session>> {
        Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "runtime persistence does not support atomic permission posture activation",
        ))
    }

    /// Persist only the runtime control-plane for a session.
    ///
    /// Task lists and other runtime metadata belong to the control-plane and do
    /// not require rewriting the potentially large message transcript. Built-in
    /// persistence implementations with a runtime sidecar should override this
    /// operation with their sidecar-only path. Custom/legacy implementations
    /// remain source-compatible and safely fall back to the full runtime save.
    ///
    /// Callers must not rely on this operation to persist message or
    /// `model_context_state` changes. The durable ledger is checkpoint-owned;
    /// sidecar implementations must preserve its latest committed value while
    /// applying the caller's narrow control-plane mutation.
    async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
        self.save_runtime_session(session).await
    }

    /// Load the representation paired with
    /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
    ///
    /// Sidecar-capable implementations should return their message-free
    /// control-plane snapshot. The default deliberately returns the full
    /// runtime session: when the paired save also falls back to a full save,
    /// retaining the transcript makes that fallback safe rather than replacing
    /// durable messages with an empty sidecar-shaped snapshot.
    async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
        self.load_runtime_session(session_id).await
    }

    /// Atomically update only the shared Task list and its version.
    ///
    /// The default is safe for custom/legacy persistence: it loads the full
    /// runtime session, changes only Task-owned fields, then uses the paired
    /// control-plane save (which itself defaults to a full save). Returning
    /// `false` means the implementation could not load the target; callers that
    /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
    /// behavior with an explicit full-load/full-save fallback.
    ///
    /// Implementations with per-session transactions should override this so
    /// the load, narrow mutation and save share one critical section.
    async fn update_task_list_control_plane(
        &self,
        session_id: &str,
        task_list: &TaskList,
        version: &str,
    ) -> io::Result<bool> {
        let Some(mut session) = self.load_runtime_session(session_id).await? else {
            return Ok(false);
        };
        session.set_task_list(task_list.clone());
        session.set_task_list_version_meta(version.to_string());
        self.save_runtime_control_plane(&mut session).await?;
        Ok(true)
    }

    /// Atomically update Task-owned control-plane fields only when the durable
    /// Task generation and exact list still match the expected snapshot.
    ///
    /// `false` covers an unsupported atomic compare-and-patch, a missing target,
    /// or a version conflict. Callers must treat it as a stale write and must
    /// not publish their staged Task state. The default fails closed because a
    /// load followed by a separately locked save is not an atomic CAS.
    async fn update_task_list_control_plane_if_version(
        &self,
        session_id: &str,
        expected_version: &str,
        expected_task_list: &TaskList,
        task_list: &TaskList,
        version: &str,
    ) -> io::Result<bool> {
        let _ = (
            session_id,
            expected_version,
            expected_task_list,
            task_list,
            version,
        );
        Ok(false)
    }

    /// Recoverably compare-and-patch the executing session and its shared root.
    /// Implementations must validate both generations before either target is
    /// written and may return `Ok(true)` only after both Task generations are
    /// durable with no undo record that could later revert them. An error after
    /// one physical write must restore both originals before returning or retain
    /// durable recovery state and fail subsequent paired access closed until
    /// recovery completes. Root-session callers pass the same id twice and
    /// receive the single-target CAS semantics above.
    async fn update_task_list_control_planes_if_version(
        &self,
        session_id: &str,
        shared_session_id: &str,
        expected_version: &str,
        expected_task_list: &TaskList,
        task_list: &TaskList,
        version: &str,
    ) -> io::Result<bool> {
        if session_id == shared_session_id {
            return self
                .update_task_list_control_plane_if_version(
                    session_id,
                    expected_version,
                    expected_task_list,
                    task_list,
                    version,
                )
                .await;
        }
        let _ = (
            session_id,
            shared_session_id,
            expected_version,
            expected_task_list,
            task_list,
            version,
        );
        Ok(false)
    }

    /// Append-safe checkpoint used at the shared engine execute boundary.
    ///
    /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
    /// preserve messages that were appended durably by a concurrent writer
    /// after the runner loaded its snapshot.  Implementations that can provide
    /// a per-session transaction should override this method and perform the
    /// load/merge/save under one lock.  The default still reconciles against a
    /// latest snapshot for lightweight/custom SDK persisters; the built-in
    /// storage implementation supplies the atomic variant.
    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
        if let Some(durable) = self.load_runtime_session(&session.id).await? {
            append_missing_runtime_messages(session, &durable);
            merge_session_inbox_admission(session, &durable);
        }
        self.save_runtime_session(session).await
    }

    /// Load the latest runtime-visible session snapshot when the persistence
    /// implementation can coordinate reads. Tools may update a repository-owned
    /// clone while an agent loop holds its own live Session; the loop uses this
    /// hook to merge narrowly-scoped tool side effects before its next save.
    async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
        Ok(None)
    }

    /// Clear the bounded compatibility queue iff it still equals the entries
    /// that were durably copied into SessionInbox. Implementations with a
    /// per-session transaction should override this method.
    async fn clear_legacy_pending_messages(
        &self,
        session_id: &str,
        expected: &[serde_json::Value],
    ) -> io::Result<bool> {
        let Some(mut latest) = self.load_runtime_session(session_id).await? else {
            return Ok(false);
        };
        if latest.pending_injected_messages().as_deref() != Some(expected) {
            return Ok(false);
        }
        latest.clear_pending_injected_messages();
        self.save_runtime_session(&mut latest).await?;
        Ok(true)
    }

    /// Append one JSON-line analysis record to the session's append-only
    /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
    /// a no-op so non-file-backed persisters are unaffected.
    ///
    /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
        let _ = (session_id, json_line);
        Ok(())
    }
}

#[async_trait::async_trait]
impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
        (**self).save_runtime_session(session).await
    }

    async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
        (**self).seed_runtime_activation(session).await
    }

    async fn record_permission_posture_activation(
        &self,
        session_id: &str,
        expected_audit_revision: Option<u64>,
        seed: &PermissionAuditSeed,
    ) -> io::Result<Option<Session>> {
        (**self)
            .record_permission_posture_activation(session_id, expected_audit_revision, seed)
            .await
    }

    async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
        (**self).save_runtime_control_plane(session).await
    }

    async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
        (**self).load_runtime_control_plane(session_id).await
    }

    async fn update_task_list_control_plane(
        &self,
        session_id: &str,
        task_list: &TaskList,
        version: &str,
    ) -> io::Result<bool> {
        (**self)
            .update_task_list_control_plane(session_id, task_list, version)
            .await
    }

    async fn update_task_list_control_plane_if_version(
        &self,
        session_id: &str,
        expected_version: &str,
        expected_task_list: &TaskList,
        task_list: &TaskList,
        version: &str,
    ) -> io::Result<bool> {
        (**self)
            .update_task_list_control_plane_if_version(
                session_id,
                expected_version,
                expected_task_list,
                task_list,
                version,
            )
            .await
    }

    async fn update_task_list_control_planes_if_version(
        &self,
        session_id: &str,
        shared_session_id: &str,
        expected_version: &str,
        expected_task_list: &TaskList,
        task_list: &TaskList,
        version: &str,
    ) -> io::Result<bool> {
        (**self)
            .update_task_list_control_planes_if_version(
                session_id,
                shared_session_id,
                expected_version,
                expected_task_list,
                task_list,
                version,
            )
            .await
    }

    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
        (**self).checkpoint_runtime_session(session).await
    }

    async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
        (**self).load_runtime_session(session_id).await
    }

    async fn clear_legacy_pending_messages(
        &self,
        session_id: &str,
        expected: &[serde_json::Value],
    ) -> io::Result<bool> {
        (**self)
            .clear_legacy_pending_messages(session_id, expected)
            .await
    }

    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
        (**self)
            .append_token_usage_record(session_id, json_line)
            .await
    }
}