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/// Immutable execution provenance for an already-persisted untagged Child wait.
20/// This capability is captured once from the execution's initial snapshot; it
21/// is not Session metadata and must never be reconstructed at a later save.
22#[derive(Debug, Clone)]
23pub struct InheritedChildWait {
24    session_id: String,
25    created_at: chrono::DateTime<chrono::Utc>,
26    wait: crate::WaitingForChildrenState,
27}
28
29impl InheritedChildWait {
30    pub fn capture(session: &Session) -> Option<Self> {
31        let wait = session
32            .agent_runtime_state
33            .as_ref()?
34            .waiting_for_children
35            .as_ref()?;
36        if wait.registered_by_tool_call_id.is_some() {
37            return None;
38        }
39        Some(Self {
40            session_id: session.id.clone(),
41            created_at: session.created_at,
42            wait: wait.clone(),
43        })
44    }
45
46    pub fn session_id(&self) -> &str {
47        &self.session_id
48    }
49
50    pub fn wait(&self) -> &crate::WaitingForChildrenState {
51        &self.wait
52    }
53
54    pub fn validate_session(&self, session: &Session) -> io::Result<()> {
55        if session.id != self.session_id || session.created_at != self.created_at {
56            return Err(io::Error::other(crate::SessionAuthorityConflict(
57                "inherited child wait session changed identity or birth".into(),
58            )));
59        }
60        Ok(())
61    }
62}
63
64/// Provider-boundary admission through the bound Root writer. None from the
65/// port below means this execution does not use ordinary Root authority.
66#[derive(Debug, Default)]
67pub struct RootInboxAdmission {
68    pub merged: usize,
69    pub committed_messages: Vec<crate::Message>,
70    pub admission_error: Option<String>,
71}
72
73/// Merge messages from a live runner snapshot into an already-durable
74/// transcript without ever removing or rewriting a durable message.
75///
76/// Runtime sessions are append-oriented, and every newly-created message has a
77/// stable id.  A runner may nevertheless be holding a snapshot that predates a
78/// concurrent append (for example, an injected child-completion message).  A
79/// terminal/error checkpoint must not full-save that stale snapshot: doing so
80/// would shrink the transcript.  Keep the durable ordering and append only the
81/// live messages whose ids are not durable yet.
82pub fn append_missing_runtime_messages(session: &mut Session, durable: &Session) -> usize {
83    let mut seen = durable
84        .messages
85        .iter()
86        .map(|message| message.id.clone())
87        .collect::<std::collections::HashSet<_>>();
88    let missing = session
89        .messages
90        .iter()
91        .filter(|message| seen.insert(message.id.clone()))
92        .cloned()
93        .collect::<Vec<_>>();
94    let appended = missing.len();
95    session.messages = durable.messages.iter().cloned().chain(missing).collect();
96    // Provider-native groups are message-anchored and append-only as well. A
97    // concurrent durable prefix must not be erased by a stale runner save, and
98    // a runner's newly completed group must remain paired with its new message.
99    session.merge_provider_transcript_from_durable(durable);
100    appended
101}
102
103/// Merge the durable SessionInbox admitted-id cursor into a writer snapshot.
104///
105/// Runtime writers can hold a session clone from before another run admitted
106/// an inbox message. No later full save may erase that durable dedupe state.
107pub fn merge_session_inbox_admission(session: &mut Session, durable: &Session) {
108    let Some(durable_state) = durable.session_inbox_admission().cloned() else {
109        return;
110    };
111    session
112        .session_inbox_admission_mut()
113        .merge_from(&durable_state);
114}
115
116/// Restore durable provider messages identified by their typed
117/// `metadata.session_message` marker into a stale writer without preserving
118/// unrelated durable suffixes. The bounded cursor is only a fast recent index;
119/// the transcript marker is the unbounded source of truth after cursor
120/// eviction.
121///
122/// Insertion follows durable transcript neighbors so an admitted user/runtime
123/// message remains ahead of any later assistant output held by the stale
124/// runner. This is narrower than [`append_missing_runtime_messages`], retaining
125/// the historical shrink semantics for unrelated concurrent messages while
126/// making a cursor/tombstone incapable of outliving its transcript entry.
127pub fn restore_missing_admitted_inbox_messages(session: &mut Session, durable: &Session) -> usize {
128    let admission = durable.session_inbox_admission();
129    let mut restored = 0;
130    for (durable_index, message) in durable.messages.iter().enumerate() {
131        let typed_marker = message
132            .metadata
133            .as_ref()
134            .and_then(|metadata| metadata.get("session_message"))
135            .is_some_and(|marker| {
136                marker.get("id").and_then(serde_json::Value::as_str) == Some(message.id.as_str())
137                    && marker
138                        .get("target_session_id")
139                        .and_then(serde_json::Value::as_str)
140                        == Some(durable.id.as_str())
141                    && crate::SessionMessageId::parse(message.id.clone()).is_ok()
142            });
143        let recent_cursor = admission.is_some_and(|state| state.contains_str(&message.id));
144        if !(typed_marker || recent_cursor)
145            || session
146                .messages
147                .iter()
148                .any(|current| current.id == message.id)
149        {
150            continue;
151        }
152
153        let insertion = durable.messages[..durable_index]
154            .iter()
155            .rev()
156            .find_map(|predecessor| {
157                session
158                    .messages
159                    .iter()
160                    .position(|current| current.id == predecessor.id)
161                    .map(|index| index + 1)
162            })
163            .or_else(|| {
164                durable.messages[durable_index + 1..]
165                    .iter()
166                    .find_map(|successor| {
167                        session
168                            .messages
169                            .iter()
170                            .position(|current| current.id == successor.id)
171                    })
172            })
173            .unwrap_or(session.messages.len());
174        session.messages.insert(insertion, message.clone());
175        restored += 1;
176    }
177    restored
178}
179
180/// Port for runtime (non-authoritative) session persistence.
181///
182/// Implementors must:
183/// - Serialize concurrent saves per session ID.
184/// - Merge on-disk authoritative metadata (`title`, `title_generated`, `pinned`, `title_version`,
185///   `metadata_version`) before writing, so UI edits are never clobbered.
186#[async_trait::async_trait]
187pub trait RuntimeSessionPersistence: Send + Sync {
188    /// An execution-private binding, never reconstructed from mutable state.
189    fn inherited_child_wait(&self) -> Option<InheritedChildWait> {
190        None
191    }
192
193    /// Return an execution-private view retaining the captured wait provenance
194    /// through every intermediate save. Unsupported persisters fail closed.
195    fn bind_inherited_child_wait(
196        &self,
197        _inherited: InheritedChildWait,
198    ) -> io::Result<Arc<dyn RuntimeSessionPersistence>> {
199        Err(io::Error::new(
200            io::ErrorKind::Unsupported,
201            crate::SessionAuthorityConflict(
202                "execution-scoped inherited child wait persistence is unsupported".into(),
203            ),
204        ))
205    }
206
207    /// Immutable capability already captured by this concrete execution.
208    /// An unbound Host/default persister never supplies a current owner.
209    fn root_actor_writer(&self) -> Option<crate::RootActorRuntimeWrite> {
210        None
211    }
212
213    /// Execution-private input restriction. A selector carries no authority;
214    /// only a persister already bound to a real Root writer may install it.
215    /// Unsupported hosts fail closed. This introduces no durable state.
216    fn bind_root_inbox_receipt(
217        &self,
218        _receipt: crate::SessionInboxReceipt,
219    ) -> io::Result<Arc<dyn RuntimeSessionPersistence>> {
220        Err(io::Error::new(
221            io::ErrorKind::Unsupported,
222            "Root receipt binding unsupported",
223        ))
224    }
225
226    fn root_inbox_receipt(&self) -> Option<&crate::SessionInboxReceipt> {
227        None
228    }
229
230    /// Whether this Host requires an explicit execution capability for the
231    /// proposed Root. A route without a bound event/persistence handoff must
232    /// reject before claiming or making a provider call.
233    fn root_actor_execution_required(&self, _session: &Session) -> bool {
234        false
235    }
236
237    /// Host execution binding, never a request DTO. Legacy embedders have no
238    /// ActorDirectory; a host that enables one must reject unsupported storage
239    /// before claiming rather than returning an unfenced writer.
240    async fn bind_root_actor_execution(
241        &self,
242        _session: &Session,
243        _run_id: &str,
244    ) -> io::Result<Option<RootActorExecutionBinding>> {
245        Ok(None)
246    }
247
248    /// Claim the same fenced Root writer for direct compaction without
249    /// attributing its writes or completion to a chat run.
250    async fn bind_root_actor_compaction(
251        &self,
252        _session: &Session,
253        _run_id: &str,
254    ) -> io::Result<Option<RootActorExecutionBinding>> {
255        Ok(None)
256    }
257
258    /// Persist the session, merging any newer authoritative metadata from disk.
259    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()>;
260
261    /// Whether this private writer supports the physical profile check.
262    fn supports_bound_startup_session(&self) -> bool {
263        false
264    }
265
266    /// Persist a Root-bound transition only if its original execution profile
267    /// still matches under the backend's physical writer and Root authority.
268    /// Unsupported persisters must never emulate this with a separate read/save.
269    async fn save_bound_startup_session(
270        &self,
271        _session: &mut Session,
272        _expected: &Session,
273    ) -> io::Result<()> {
274        Err(io::Error::new(
275            io::ErrorKind::Unsupported,
276            crate::SessionAuthorityConflict(
277                "conditional bound startup persistence is unsupported".into(),
278            ),
279        ))
280    }
281
282    /// Persist a runner's final snapshot. Implementations with a per-session
283    /// lock should reconcile a tool-registered child wait against the latest
284    /// durable wait inside that lock: a child may finish after the runner's
285    /// last read but before this save. The default retains compatibility for
286    /// in-memory and test persisters without that concurrent completion path.
287    async fn save_finalized_runtime_session(&self, session: &mut Session) -> io::Result<()> {
288        self.save_runtime_session(session).await
289    }
290
291    /// Finalize a wait inherited by this execution. Backends must reconcile
292    /// against durable state inside their cross-process write transaction.
293    /// Unsupported adapters fail closed rather than reopen a read/save race.
294    async fn save_finalized_runtime_with_inherited_child_wait(
295        &self,
296        session: &mut Session,
297        inherited: &crate::session::runtime_state::WaitingForChildrenState,
298    ) -> io::Result<()> {
299        let _ = (session, inherited);
300        Err(io::Error::new(
301            io::ErrorKind::Unsupported,
302            crate::SessionAuthorityConflict(
303                "atomic inherited child wait finalization is unsupported".into(),
304            ),
305        ))
306    }
307
308    /// Authoritatively seed one validated actor activation.
309    ///
310    /// Unlike an ordinary runtime save, the incoming RunSpec posture and its
311    /// complete audit record must replace any posture left by a previous warm
312    /// activation. Implementations must still preserve durable SessionInbox
313    /// admission/transcript proof and serialize the operation per session.
314    ///
315    /// There is no safe generic implementation through
316    /// [`Self::save_runtime_session`]: that primitive is explicitly allowed to
317    /// adopt a newer disk posture, which would make warm workers sticky across
318    /// runs. Custom persisters therefore fail closed until they implement this
319    /// authority boundary deliberately.
320    async fn seed_runtime_activation(&self, _session: &mut Session) -> io::Result<()> {
321        Err(io::Error::new(
322            io::ErrorKind::Unsupported,
323            "runtime persistence does not support authoritative activation seeding",
324        ))
325    }
326
327    /// Atomically persist a worker-declared executor mapping for the current
328    /// host-authoritative permission posture.
329    ///
330    /// The caller supplies the audit revision it observed before dispatch.
331    /// Implementations must load and compare that revision while holding the
332    /// per-session lock, reject a concurrent posture update, and allocate a new
333    /// host revision/timestamp themselves. Remote audit clocks are never an
334    /// authority at this boundary.
335    async fn record_permission_posture_activation(
336        &self,
337        _session_id: &str,
338        _expected_audit_revision: Option<u64>,
339        _seed: &PermissionAuditSeed,
340    ) -> io::Result<Option<Session>> {
341        Err(io::Error::new(
342            io::ErrorKind::Unsupported,
343            "runtime persistence does not support atomic permission posture activation",
344        ))
345    }
346
347    /// Persist only the runtime control-plane for a session.
348    ///
349    /// Task lists and other runtime metadata belong to the control-plane and do
350    /// not require rewriting the potentially large message transcript. Built-in
351    /// persistence implementations with a runtime sidecar should override this
352    /// operation with their sidecar-only path. Custom/legacy implementations
353    /// remain source-compatible and safely fall back to the full runtime save.
354    ///
355    /// Callers must not rely on this operation to persist message or
356    /// `model_context_state` changes. The durable ledger is checkpoint-owned;
357    /// sidecar implementations must preserve its latest committed value while
358    /// applying the caller's narrow control-plane mutation.
359    async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
360        self.save_runtime_session(session).await
361    }
362
363    /// Load the representation paired with
364    /// [`save_runtime_control_plane`](Self::save_runtime_control_plane).
365    ///
366    /// Sidecar-capable implementations should return their message-free
367    /// control-plane snapshot. The default deliberately returns the full
368    /// runtime session: when the paired save also falls back to a full save,
369    /// retaining the transcript makes that fallback safe rather than replacing
370    /// durable messages with an empty sidecar-shaped snapshot.
371    async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
372        self.load_runtime_session(session_id).await
373    }
374
375    /// Atomically update only the shared Task list and its version.
376    ///
377    /// The default is safe for custom/legacy persistence: it loads the full
378    /// runtime session, changes only Task-owned fields, then uses the paired
379    /// control-plane save (which itself defaults to a full save). Returning
380    /// `false` means the implementation could not load the target; callers that
381    /// also hold a [`Storage`](crate::storage::Storage) may retain legacy
382    /// behavior with an explicit full-load/full-save fallback.
383    ///
384    /// Implementations with per-session transactions should override this so
385    /// the load, narrow mutation and save share one critical section.
386    async fn update_task_list_control_plane(
387        &self,
388        session_id: &str,
389        task_list: &TaskList,
390        version: &str,
391    ) -> io::Result<bool> {
392        let Some(mut session) = self.load_runtime_session(session_id).await? else {
393            return Ok(false);
394        };
395        session.set_task_list(task_list.clone());
396        session.set_task_list_version_meta(version.to_string());
397        self.save_runtime_control_plane(&mut session).await?;
398        Ok(true)
399    }
400
401    /// Atomically update Task-owned control-plane fields only when the durable
402    /// Task generation and exact list still match the expected snapshot.
403    ///
404    /// `false` covers an unsupported atomic compare-and-patch, a missing target,
405    /// or a version conflict. Callers must treat it as a stale write and must
406    /// not publish their staged Task state. The default fails closed because a
407    /// load followed by a separately locked save is not an atomic CAS.
408    async fn update_task_list_control_plane_if_version(
409        &self,
410        session_id: &str,
411        expected_version: &str,
412        expected_task_list: &TaskList,
413        task_list: &TaskList,
414        version: &str,
415    ) -> io::Result<bool> {
416        let _ = (
417            session_id,
418            expected_version,
419            expected_task_list,
420            task_list,
421            version,
422        );
423        Ok(false)
424    }
425
426    /// Recoverably compare-and-patch the executing session and its shared root.
427    /// Implementations must validate both generations before either target is
428    /// written and may return `Ok(true)` only after both Task generations are
429    /// durable with no undo record that could later revert them. An error after
430    /// one physical write must restore both originals before returning or retain
431    /// durable recovery state and fail subsequent paired access closed until
432    /// recovery completes. Root-session callers pass the same id twice and
433    /// receive the single-target CAS semantics above.
434    async fn update_task_list_control_planes_if_version(
435        &self,
436        session_id: &str,
437        shared_session_id: &str,
438        expected_version: &str,
439        expected_task_list: &TaskList,
440        task_list: &TaskList,
441        version: &str,
442    ) -> io::Result<bool> {
443        if session_id == shared_session_id {
444            return self
445                .update_task_list_control_plane_if_version(
446                    session_id,
447                    expected_version,
448                    expected_task_list,
449                    task_list,
450                    version,
451                )
452                .await;
453        }
454        let _ = (
455            session_id,
456            shared_session_id,
457            expected_version,
458            expected_task_list,
459            task_list,
460            version,
461        );
462        Ok(false)
463    }
464
465    /// Append-safe checkpoint used at the shared engine execute boundary.
466    ///
467    /// Unlike [`save_runtime_session`](Self::save_runtime_session), this must
468    /// preserve messages that were appended durably by a concurrent writer
469    /// after the runner loaded its snapshot.  Implementations that can provide
470    /// a per-session transaction should override this method and perform the
471    /// load/merge/save under one lock.  The default still reconciles against a
472    /// latest snapshot for lightweight/custom SDK persisters; the built-in
473    /// storage implementation supplies the atomic variant.
474    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
475        if let Some(durable) = self.load_runtime_session(&session.id).await? {
476            session
477                .check_conversation_generation(&durable)
478                .map_err(|error| {
479                    io::Error::new(
480                        io::ErrorKind::WouldBlock,
481                        crate::SessionAuthorityConflict(error.to_string()),
482                    )
483                })?;
484            append_missing_runtime_messages(session, &durable);
485            merge_session_inbox_admission(session, &durable);
486        }
487        self.save_runtime_session(session).await
488    }
489
490    /// Bound execution only; unsupported/custom adapters cannot authorize a
491    /// continuation by falling back to generic checkpoint or snapshot reads.
492    async fn checkpoint_live_bootstrap(
493        &self,
494        _session: &Session,
495        _inbox: Arc<dyn crate::SessionInboxPort>,
496        _gate: crate::AdmissionGate,
497    ) -> io::Result<crate::LiveBootstrapProof> {
498        Err(io::Error::new(
499            io::ErrorKind::Unsupported,
500            "live bootstrap checkpoint unsupported",
501        ))
502    }
503
504    async fn confirm_live_bootstrap(
505        &self,
506        _proof: &crate::LiveBootstrapProof,
507        _inbox: Arc<dyn crate::SessionInboxPort>,
508        _publish: crate::RootActorRuntimeEventPublisher,
509    ) -> io::Result<()> {
510        Err(io::Error::new(
511            io::ErrorKind::Unsupported,
512            "live bootstrap confirmation unsupported",
513        ))
514    }
515
516    /// Only the existing owner may claim structured Loop inputs. Unsupported
517    /// adapters do not fall back to ordinary provider-message admission.
518    async fn claim_scoped_loop_controls(
519        &self,
520        _session: &Session,
521        _inbox: Arc<dyn crate::SessionInboxPort>,
522        _active_run_id: &str,
523        _limit: usize,
524    ) -> io::Result<Vec<crate::ScopedLoopControlClaim>> {
525        Err(io::Error::new(
526            io::ErrorKind::Unsupported,
527            "scoped Loop claims unsupported",
528        ))
529    }
530
531    /// State, immutable control outcome and typed consumption proof share the
532    /// actual target checkpoint; ACK is last. Err never means rollback.
533    async fn checkpoint_scoped_loop_control(
534        &self,
535        _session: &mut Session,
536        _bindings: &[crate::ScopedLoopControlBinding],
537        _activation: Arc<dyn crate::SessionActivationPort>,
538        _input: Option<(
539            Arc<dyn crate::SessionInboxPort>,
540            crate::ScopedLoopControlClaim,
541        )>,
542    ) -> io::Result<()> {
543        Err(io::Error::new(
544            io::ErrorKind::Unsupported,
545            "scoped Loop checkpoint unsupported",
546        ))
547    }
548
549    /// Canonical read after a failed/uncertain checkpoint. A cached Session or
550    /// the proposed caller snapshot is not confirmation of what was written.
551    async fn load_loop_control_checkpoint(&self, _session_id: &str) -> io::Result<Option<Session>> {
552        Err(io::Error::new(
553            io::ErrorKind::Unsupported,
554            "strict Loop checkpoint reload unsupported",
555        ))
556    }
557
558    async fn admit_root_inbox(
559        &self,
560        _session: &mut Session,
561        _inbox: Arc<dyn crate::SessionInboxPort>,
562        _active_run_id: Option<&str>,
563    ) -> io::Result<Option<RootInboxAdmission>> {
564        Ok(None)
565    }
566
567    /// Atomically commit a staged archive transcript rewrite.
568    ///
569    /// `expected_base` is the exact pre-archive Session used for planning.
570    /// Implementations must compare it with the latest durable transcript while
571    /// holding their per-session serialization lock. If a concurrent append or
572    /// rewrite is present, they must perform no save, rebase `staged` onto that
573    /// durable snapshot, and return [`RetrievalWindowCheckpointOutcome::Rebased`].
574    /// Otherwise they must preserve the staged message archive flags and commit
575    /// them with the compression event and model-context reset. The built-in
576    /// implementation also accepts a MessageSummary archive through this same
577    /// boundary, preserving raw history while replacing its derived summary.
578    ///
579    /// There is no safe fallback through the ordinary append-only checkpoint,
580    /// because that path deliberately restores durable message clones and would
581    /// erase the new archive flags. Custom persisters therefore fail closed
582    /// until they implement this boundary explicitly.
583    async fn checkpoint_retrieval_window(
584        &self,
585        _expected_base: &Session,
586        _staged: &mut Session,
587    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
588        Err(io::Error::new(
589            io::ErrorKind::Unsupported,
590            "runtime persistence does not support retrieval-window checkpoints",
591        ))
592    }
593
594    /// Atomically commit a provider-visible System-prompt rewrite.
595    ///
596    /// This is deliberately separate from both the append-safe runtime
597    /// checkpoint (which must restore durable message content) and the
598    /// retrieval-window archive checkpoint (which requires a new archive
599    /// event). Implementations compare `expected_base` under their per-session
600    /// lock, return `Rebased` without writing on conflict, and accept only the
601    /// bounded prompt rewrite plus its provider/model-context reset.
602    async fn checkpoint_prompt_rewrite(
603        &self,
604        _expected_base: &Session,
605        _staged: &mut Session,
606    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
607        Err(io::Error::new(
608            io::ErrorKind::Unsupported,
609            "runtime persistence does not support prompt-rewrite checkpoints",
610        ))
611    }
612
613    /// Atomically commit one permanently rejected `archive_context` result.
614    ///
615    /// This boundary permits only the correlated Tool result rewrite, the
616    /// bounded consumed/rejection metadata, and the provider/model-context
617    /// reset required to make that rewrite visible. Implementations compare
618    /// `expected_base` under their per-session lock and return `Rebased`
619    /// without writing when the durable transcript changed concurrently.
620    async fn checkpoint_manual_archive_rejection(
621        &self,
622        _expected_base: &Session,
623        _staged: &mut Session,
624    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
625        Err(io::Error::new(
626            io::ErrorKind::Unsupported,
627            "runtime persistence does not support manual archive-rejection checkpoints",
628        ))
629    }
630
631    /// Atomically consume one successful no-op `archive_context` request.
632    ///
633    /// This boundary permits only the correlated consumed-occurrence marker.
634    /// Implementations compare `expected_base` under their per-session lock
635    /// and return `Rebased` without writing when any durable transcript,
636    /// metadata, runtime metadata, or execution-profile field changed. The
637    /// caller must then restage the marker from the returned durable snapshot.
638    async fn checkpoint_manual_archive_consumption(
639        &self,
640        _expected_base: &Session,
641        _staged: &mut Session,
642    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
643        Err(io::Error::new(
644            io::ErrorKind::Unsupported,
645            "runtime persistence does not support manual archive-consumption checkpoints",
646        ))
647    }
648
649    /// Load the latest runtime-visible session snapshot when the persistence
650    /// implementation can coordinate reads. Tools may update a repository-owned
651    /// clone while an agent loop holds its own live Session; the loop uses this
652    /// hook to merge narrowly-scoped tool side effects before its next save.
653    async fn load_runtime_session(&self, _session_id: &str) -> io::Result<Option<Session>> {
654        Ok(None)
655    }
656
657    /// Read a canonical snapshot for execution admission without publishing or
658    /// trusting a cached clone. Preserve the same bound-reader checks as the
659    /// runtime loader. A persister with a cache must override this operation to
660    /// read its durable source (or return Unsupported); the default serves
661    /// uncached/legacy loaders. This read never grants execution/write authority.
662    async fn load_runtime_admission_snapshot(
663        &self,
664        session_id: &str,
665    ) -> io::Result<Option<Session>> {
666        self.load_runtime_session(session_id).await
667    }
668
669    /// Clear the bounded compatibility queue iff it still equals the entries
670    /// that were durably copied into SessionInbox. Implementations with a
671    /// per-session transaction should override this method.
672    async fn clear_legacy_pending_messages(
673        &self,
674        session_id: &str,
675        expected: &[serde_json::Value],
676    ) -> io::Result<bool> {
677        let Some(mut latest) = self.load_runtime_session(session_id).await? else {
678            return Ok(false);
679        };
680        if latest.pending_injected_messages().as_deref() != Some(expected) {
681            return Ok(false);
682        }
683        latest.clear_pending_injected_messages();
684        self.save_runtime_session(&mut latest).await?;
685        Ok(true)
686    }
687
688    /// Append one JSON-line analysis record to the session's append-only
689    /// token-usage log (see [`Storage::append_token_usage_record`]). Defaults to
690    /// a no-op so non-file-backed persisters are unaffected.
691    ///
692    /// [`Storage::append_token_usage_record`]: crate::storage::Storage::append_token_usage_record
693    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
694        let _ = (session_id, json_line);
695        Ok(())
696    }
697}
698
699/// Ownership passed from the canonical repository into one concrete runtime.
700/// The runtime owns renewal and finish; dropping an execution must release its
701/// own fence without ever finishing a replacement owner.
702pub struct RootActorExecutionBinding {
703    pub persistence: Arc<dyn RuntimeSessionPersistence>,
704    pub directory: Arc<dyn crate::ActorDirectoryPort>,
705    pub owner: crate::RootActorRuntimeWrite,
706    pub lease_duration: std::time::Duration,
707    /// Synchronous publication retained by the real terminal storage guards.
708    pub terminal_publisher: Option<crate::storage::RootActorRuntimePublisher>,
709    abandon: Option<Box<dyn FnOnce() + Send + Sync>>,
710}
711
712impl RootActorExecutionBinding {
713    pub fn new(
714        persistence: Arc<dyn RuntimeSessionPersistence>,
715        directory: Arc<dyn crate::ActorDirectoryPort>,
716        owner: crate::RootActorRuntimeWrite,
717        lease_duration: std::time::Duration,
718        abandon: impl FnOnce() + Send + Sync + 'static,
719    ) -> Self {
720        Self {
721            persistence,
722            directory,
723            owner,
724            lease_duration,
725            terminal_publisher: None,
726            abandon: Some(Box::new(abandon)),
727        }
728    }
729
730    pub fn with_terminal_publisher(
731        mut self,
732        publish: crate::storage::RootActorRuntimePublisher,
733    ) -> Self {
734        self.terminal_publisher = Some(publish);
735        self
736    }
737
738    /// The runtime calls this only after handing finish to an owned detached
739    /// job, so cancellation of the waiter cannot strand this execution.
740    pub fn disarm_abandonment(&mut self) {
741        self.abandon.take();
742    }
743}
744
745impl Drop for RootActorExecutionBinding {
746    fn drop(&mut self) {
747        if let Some(abandon) = self.abandon.take() {
748            abandon();
749        }
750    }
751}
752
753#[async_trait::async_trait]
754impl<T: RuntimeSessionPersistence + ?Sized> RuntimeSessionPersistence for Arc<T> {
755    fn inherited_child_wait(&self) -> Option<InheritedChildWait> {
756        (**self).inherited_child_wait()
757    }
758
759    fn bind_inherited_child_wait(
760        &self,
761        inherited: InheritedChildWait,
762    ) -> io::Result<Arc<dyn RuntimeSessionPersistence>> {
763        (**self).bind_inherited_child_wait(inherited)
764    }
765
766    fn root_actor_writer(&self) -> Option<crate::RootActorRuntimeWrite> {
767        (**self).root_actor_writer()
768    }
769
770    fn bind_root_inbox_receipt(
771        &self,
772        receipt: crate::SessionInboxReceipt,
773    ) -> io::Result<Arc<dyn RuntimeSessionPersistence>> {
774        (**self).bind_root_inbox_receipt(receipt)
775    }
776
777    fn root_inbox_receipt(&self) -> Option<&crate::SessionInboxReceipt> {
778        (**self).root_inbox_receipt()
779    }
780
781    fn root_actor_execution_required(&self, session: &Session) -> bool {
782        (**self).root_actor_execution_required(session)
783    }
784
785    async fn bind_root_actor_execution(
786        &self,
787        session: &Session,
788        run_id: &str,
789    ) -> io::Result<Option<RootActorExecutionBinding>> {
790        (**self).bind_root_actor_execution(session, run_id).await
791    }
792
793    async fn bind_root_actor_compaction(
794        &self,
795        session: &Session,
796        run_id: &str,
797    ) -> io::Result<Option<RootActorExecutionBinding>> {
798        (**self).bind_root_actor_compaction(session, run_id).await
799    }
800
801    async fn save_runtime_session(&self, session: &mut Session) -> io::Result<()> {
802        (**self).save_runtime_session(session).await
803    }
804
805    fn supports_bound_startup_session(&self) -> bool {
806        (**self).supports_bound_startup_session()
807    }
808
809    async fn save_bound_startup_session(
810        &self,
811        session: &mut Session,
812        expected: &Session,
813    ) -> io::Result<()> {
814        (**self).save_bound_startup_session(session, expected).await
815    }
816
817    async fn save_finalized_runtime_session(&self, session: &mut Session) -> io::Result<()> {
818        (**self).save_finalized_runtime_session(session).await
819    }
820
821    async fn save_finalized_runtime_with_inherited_child_wait(
822        &self,
823        session: &mut Session,
824        inherited: &crate::session::runtime_state::WaitingForChildrenState,
825    ) -> io::Result<()> {
826        (**self)
827            .save_finalized_runtime_with_inherited_child_wait(session, inherited)
828            .await
829    }
830
831    async fn seed_runtime_activation(&self, session: &mut Session) -> io::Result<()> {
832        (**self).seed_runtime_activation(session).await
833    }
834
835    async fn record_permission_posture_activation(
836        &self,
837        session_id: &str,
838        expected_audit_revision: Option<u64>,
839        seed: &PermissionAuditSeed,
840    ) -> io::Result<Option<Session>> {
841        (**self)
842            .record_permission_posture_activation(session_id, expected_audit_revision, seed)
843            .await
844    }
845
846    async fn save_runtime_control_plane(&self, session: &mut Session) -> io::Result<()> {
847        (**self).save_runtime_control_plane(session).await
848    }
849
850    async fn load_runtime_control_plane(&self, session_id: &str) -> io::Result<Option<Session>> {
851        (**self).load_runtime_control_plane(session_id).await
852    }
853
854    async fn update_task_list_control_plane(
855        &self,
856        session_id: &str,
857        task_list: &TaskList,
858        version: &str,
859    ) -> io::Result<bool> {
860        (**self)
861            .update_task_list_control_plane(session_id, task_list, version)
862            .await
863    }
864
865    async fn update_task_list_control_plane_if_version(
866        &self,
867        session_id: &str,
868        expected_version: &str,
869        expected_task_list: &TaskList,
870        task_list: &TaskList,
871        version: &str,
872    ) -> io::Result<bool> {
873        (**self)
874            .update_task_list_control_plane_if_version(
875                session_id,
876                expected_version,
877                expected_task_list,
878                task_list,
879                version,
880            )
881            .await
882    }
883
884    async fn update_task_list_control_planes_if_version(
885        &self,
886        session_id: &str,
887        shared_session_id: &str,
888        expected_version: &str,
889        expected_task_list: &TaskList,
890        task_list: &TaskList,
891        version: &str,
892    ) -> io::Result<bool> {
893        (**self)
894            .update_task_list_control_planes_if_version(
895                session_id,
896                shared_session_id,
897                expected_version,
898                expected_task_list,
899                task_list,
900                version,
901            )
902            .await
903    }
904
905    async fn checkpoint_runtime_session(&self, session: &mut Session) -> io::Result<()> {
906        (**self).checkpoint_runtime_session(session).await
907    }
908
909    async fn checkpoint_live_bootstrap(
910        &self,
911        session: &Session,
912        inbox: Arc<dyn crate::SessionInboxPort>,
913        gate: crate::AdmissionGate,
914    ) -> io::Result<crate::LiveBootstrapProof> {
915        (**self)
916            .checkpoint_live_bootstrap(session, inbox, gate)
917            .await
918    }
919
920    async fn confirm_live_bootstrap(
921        &self,
922        proof: &crate::LiveBootstrapProof,
923        inbox: Arc<dyn crate::SessionInboxPort>,
924        publish: crate::RootActorRuntimeEventPublisher,
925    ) -> io::Result<()> {
926        (**self).confirm_live_bootstrap(proof, inbox, publish).await
927    }
928
929    async fn claim_scoped_loop_controls(
930        &self,
931        session: &Session,
932        inbox: Arc<dyn crate::SessionInboxPort>,
933        active_run_id: &str,
934        limit: usize,
935    ) -> io::Result<Vec<crate::ScopedLoopControlClaim>> {
936        (**self)
937            .claim_scoped_loop_controls(session, inbox, active_run_id, limit)
938            .await
939    }
940
941    async fn checkpoint_scoped_loop_control(
942        &self,
943        session: &mut Session,
944        bindings: &[crate::ScopedLoopControlBinding],
945        activation: Arc<dyn crate::SessionActivationPort>,
946        input: Option<(
947            Arc<dyn crate::SessionInboxPort>,
948            crate::ScopedLoopControlClaim,
949        )>,
950    ) -> io::Result<()> {
951        (**self)
952            .checkpoint_scoped_loop_control(session, bindings, activation, input)
953            .await
954    }
955
956    async fn load_loop_control_checkpoint(&self, session_id: &str) -> io::Result<Option<Session>> {
957        (**self).load_loop_control_checkpoint(session_id).await
958    }
959
960    async fn admit_root_inbox(
961        &self,
962        session: &mut Session,
963        inbox: Arc<dyn crate::SessionInboxPort>,
964        active_run_id: Option<&str>,
965    ) -> io::Result<Option<RootInboxAdmission>> {
966        (**self)
967            .admit_root_inbox(session, inbox, active_run_id)
968            .await
969    }
970
971    async fn checkpoint_retrieval_window(
972        &self,
973        expected_base: &Session,
974        staged: &mut Session,
975    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
976        (**self)
977            .checkpoint_retrieval_window(expected_base, staged)
978            .await
979    }
980
981    async fn checkpoint_prompt_rewrite(
982        &self,
983        expected_base: &Session,
984        staged: &mut Session,
985    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
986        (**self)
987            .checkpoint_prompt_rewrite(expected_base, staged)
988            .await
989    }
990
991    async fn checkpoint_manual_archive_rejection(
992        &self,
993        expected_base: &Session,
994        staged: &mut Session,
995    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
996        (**self)
997            .checkpoint_manual_archive_rejection(expected_base, staged)
998            .await
999    }
1000
1001    async fn checkpoint_manual_archive_consumption(
1002        &self,
1003        expected_base: &Session,
1004        staged: &mut Session,
1005    ) -> io::Result<RetrievalWindowCheckpointOutcome> {
1006        (**self)
1007            .checkpoint_manual_archive_consumption(expected_base, staged)
1008            .await
1009    }
1010
1011    async fn load_runtime_session(&self, session_id: &str) -> io::Result<Option<Session>> {
1012        (**self).load_runtime_session(session_id).await
1013    }
1014
1015    async fn load_runtime_admission_snapshot(
1016        &self,
1017        session_id: &str,
1018    ) -> io::Result<Option<Session>> {
1019        (**self).load_runtime_admission_snapshot(session_id).await
1020    }
1021
1022    async fn clear_legacy_pending_messages(
1023        &self,
1024        session_id: &str,
1025        expected: &[serde_json::Value],
1026    ) -> io::Result<bool> {
1027        (**self)
1028            .clear_legacy_pending_messages(session_id, expected)
1029            .await
1030    }
1031
1032    async fn append_token_usage_record(&self, session_id: &str, json_line: &str) -> io::Result<()> {
1033        (**self)
1034            .append_token_usage_record(session_id, json_line)
1035            .await
1036    }
1037}