1pub mod queued_work;
4
5const PROC_BOOT_ID_PATH: &str = "/proc/sys/kernel/random/boot_id";
6
7fn default_root_session_id() -> String {
8 "root".to_string()
9}
10
11#[cfg(test)]
12mod persisted_state_tests {
13 use super::*;
14
15 #[test]
16 fn persisted_state_hydrates_provider_id_without_live_provider_rebinding() {
17 let state = persisted_session_state_from_head(
18 SessionHead {
19 session_id: "stored".to_string(),
20 head_revision: 7,
21 agent_frames: Vec::new(),
22 current_agent_frame_id: String::new(),
23 graph: crate::SessionGraph::default(),
24 config: crate::PersistedSessionConfig {
25 provider_id: "stored-provider".to_string(),
26 model: crate::ModelSpec::default(),
27 },
28 checkpoint_ref: None,
29 token_ledger: Vec::new(),
30 },
31 None,
32 );
33
34 assert_eq!(state.policy.recorded_provider_id(), "stored-provider");
35 assert!(
36 state
37 .agent_frames
38 .iter()
39 .all(|frame| frame.assignment.policy.recorded_provider_id() == "stored-provider")
40 );
41 assert_eq!(state.head_revision, Some(7));
42 }
43}
44
45#[derive(Debug, thiserror::Error)]
46pub enum StoreError {
47 #[error(
48 "store is already bound to session `{bound_session_id}` and cannot be reused for `{attempted_session_id}`"
49 )]
50 SessionBindingMismatch {
51 bound_session_id: String,
52 attempted_session_id: String,
53 },
54 #[error("store does not support read scope {0:?}")]
55 UnsupportedReadScope(SessionReadScope),
56 #[error("store head revision conflict: expected {expected:?}, actual {actual}")]
57 HeadRevisionConflict { expected: Option<u64>, actual: u64 },
58 #[error(
59 "runtime turn `{turn_id}` for session `{session_id}` was already committed with a different commit hash"
60 )]
61 RuntimeTurnCommitConflict { session_id: String, turn_id: String },
62 #[error("queued work claim `{claim_id}` for session `{session_id}` is missing or expired")]
63 QueuedWorkClaimExpired {
64 session_id: String,
65 claim_id: String,
66 },
67 #[error("turn input claim `{claim_id}` for session `{session_id}` is missing or expired")]
68 TurnInputClaimExpired {
69 session_id: String,
70 claim_id: String,
71 },
72 #[error("session execution lease for session `{session_id}` is missing or expired")]
73 SessionExecutionLeaseExpired { session_id: String },
74 #[error(
75 "{record_kind} schema_version {actual} is not supported by this binary (expected {expected})"
76 )]
77 UnsupportedRecordSchemaVersion {
78 record_kind: &'static str,
79 actual: u32,
80 expected: u32,
81 },
82 #[error("store backend error: {0}")]
83 Backend(String),
84}
85
86#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
87pub struct SessionMeta {
88 pub session_id: String,
89 pub session_name: String,
90 pub created_at: String,
91 pub model: String,
92 pub cwd: Option<String>,
93 pub relation: crate::SessionRelation,
94}
95
96impl SessionMeta {
97 pub fn parent_session_id(&self) -> Option<&str> {
100 self.relation.parent_session_id()
101 }
102}
103
104#[derive(Clone, Debug)]
106pub struct SessionPickerInfo {
107 pub session_id: String,
108 pub cwd: Option<String>,
109 pub relation: crate::SessionRelation,
110 pub first_user_message: String,
111 pub user_message_count: usize,
112}
113
114impl SessionPickerInfo {
115 pub fn parent_session_id(&self) -> Option<&str> {
116 self.relation.parent_session_id()
117 }
118}
119
120#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
121#[serde(transparent)]
122pub struct BlobRef(pub String);
123
124impl BlobRef {
125 pub fn as_str(&self) -> &str {
126 &self.0
127 }
128}
129
130impl std::fmt::Display for BlobRef {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.write_str(&self.0)
133 }
134}
135
136impl From<String> for BlobRef {
137 fn from(value: String) -> Self {
138 Self(value)
139 }
140}
141
142#[derive(Clone, Debug, Default, PartialEq, Eq)]
143pub struct GcReport {
144 pub root_count: usize,
145 pub retained_blob_count: usize,
146 pub deleted_blob_count: usize,
147}
148
149#[derive(Clone, Debug, Default, PartialEq, Eq)]
153pub struct VacuumReport {
154 pub removed_node_count: usize,
155}
156
157#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
158pub struct SessionCheckpoint {
159 #[serde(default)]
160 pub turn_state: crate::PersistedTurnState,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub tool_state_ref: Option<BlobRef>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub plugin_snapshot_ref: Option<BlobRef>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub plugin_snapshot_revision: Option<u64>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub execution_state_ref: Option<BlobRef>,
169}
170
171#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
172pub struct HydratedSessionCheckpoint {
173 pub turn_state: crate::PersistedTurnState,
174 pub tool_state_ref: Option<BlobRef>,
175 pub tool_state: Option<crate::ToolState>,
176 pub plugin_snapshot_ref: Option<BlobRef>,
177 pub plugin_snapshot: Option<crate::PluginSessionSnapshot>,
178 pub plugin_snapshot_revision: Option<u64>,
179 pub execution_state_ref: Option<BlobRef>,
180 pub execution_state: Option<Vec<u8>>,
181}
182
183#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
184pub struct SessionHead {
185 #[serde(default = "default_root_session_id")]
186 pub session_id: String,
187 #[serde(default)]
188 pub head_revision: u64,
189 #[serde(default)]
190 pub agent_frames: Vec<crate::AgentFrameRecord>,
191 #[serde(default, skip_serializing_if = "String::is_empty")]
192 pub current_agent_frame_id: crate::AgentFrameId,
193 pub graph: crate::SessionGraph,
194 pub config: crate::PersistedSessionConfig,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub checkpoint_ref: Option<BlobRef>,
197 #[serde(default, skip_serializing_if = "Vec::is_empty")]
198 pub token_ledger: Vec<crate::TokenLedgerEntry>,
199}
200
201#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
202pub struct SessionHeadMeta {
203 #[serde(default = "default_root_session_id")]
204 pub session_id: String,
205 #[serde(default)]
206 pub head_revision: u64,
207 pub config: crate::PersistedSessionConfig,
208 #[serde(default)]
209 pub agent_frames: Vec<crate::AgentFrameRecord>,
210 #[serde(default, skip_serializing_if = "String::is_empty")]
211 pub current_agent_frame_id: crate::AgentFrameId,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub checkpoint_ref: Option<BlobRef>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub leaf_node_id: Option<String>,
216 #[serde(default)]
217 pub graph_node_count: usize,
218 #[serde(default, skip_serializing_if = "Vec::is_empty")]
219 pub token_ledger: Vec<crate::TokenLedgerEntry>,
220}
221
222fn persisted_session_config_from_state(
223 state: &crate::RuntimeSessionState,
224) -> crate::PersistedSessionConfig {
225 crate::PersistedSessionConfig {
226 provider_id: state.policy.recorded_provider_id().to_string(),
227 model: state.policy.model.clone(),
228 }
229}
230
231#[derive(Clone, Debug, PartialEq, Eq)]
232pub enum SessionReadScope {
233 FullGraph,
234 ActivePath { leaf_node_id: Option<String> },
235}
236
237#[derive(Clone, Debug)]
238pub struct PersistedSessionRead {
239 pub session_id: String,
240 pub head_revision: u64,
241 pub config: crate::PersistedSessionConfig,
242 pub agent_frames: Vec<crate::AgentFrameRecord>,
243 pub current_agent_frame_id: crate::AgentFrameId,
244 pub graph: crate::SessionGraph,
245 pub checkpoint_ref: Option<BlobRef>,
246 pub checkpoint: Option<HydratedSessionCheckpoint>,
247 pub token_ledger: Vec<crate::TokenLedgerEntry>,
248}
249
250#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
251pub enum GraphCommitDelta {
252 Unchanged {
253 leaf_node_id: Option<String>,
254 },
255 Append {
256 nodes: Vec<crate::SessionNodeRecord>,
257 leaf_node_id: Option<String>,
258 },
259 ReplaceFull(crate::SessionGraph),
260}
261
262impl GraphCommitDelta {
263 pub fn leaf_node_id(&self) -> Option<&String> {
264 match self {
265 Self::Unchanged { leaf_node_id } | Self::Append { leaf_node_id, .. } => {
266 leaf_node_id.as_ref()
267 }
268 Self::ReplaceFull(graph) => graph.leaf_node_id.as_ref(),
269 }
270 }
271}
272
273#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
274pub struct RuntimeCommit {
275 pub session_id: String,
276 pub expected_head_revision: Option<u64>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub session_execution_lease: Option<SessionExecutionLeaseFence>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub release_session_execution_lease: Option<SessionExecutionLeaseCompletion>,
281 pub config: crate::PersistedSessionConfig,
282 pub agent_frames: Vec<crate::AgentFrameRecord>,
283 pub current_agent_frame_id: crate::AgentFrameId,
284 pub graph: GraphCommitDelta,
285 pub checkpoint: HydratedSessionCheckpoint,
286 pub usage_deltas: Vec<crate::TokenLedgerEntry>,
287 pub turn_commit: Option<RuntimeTurnCommitStamp>,
288 pub completed_queue_claims: Vec<crate::QueuedWorkCompletion>,
289 pub completed_turn_input_claims: Vec<crate::TurnInputCompletion>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub interrupted_turn_input_turn_id: Option<String>,
292 pub committed_attachment_ids: Vec<crate::AttachmentId>,
300}
301
302#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
303pub struct RuntimeCommitResult {
304 pub head_revision: u64,
305 pub checkpoint_ref: BlobRef,
306 pub manifest: SessionCheckpoint,
307}
308
309#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
310pub struct LeaseOwnerIdentity {
311 pub owner_id: String,
312 pub incarnation_id: String,
313 #[serde(default)]
314 pub liveness: LeaseOwnerLiveness,
315}
316
317impl LeaseOwnerIdentity {
318 pub fn opaque(
319 owner_id: impl Into<String>,
320 incarnation_id: impl Into<String>,
321 ) -> LeaseOwnerIdentity {
322 LeaseOwnerIdentity {
323 owner_id: owner_id.into(),
324 incarnation_id: incarnation_id.into(),
325 liveness: LeaseOwnerLiveness::Opaque,
326 }
327 }
328
329 pub fn local_process(
330 owner_id: impl Into<String>,
331 incarnation_id: impl Into<String>,
332 host_id: impl Into<String>,
333 ) -> LeaseOwnerIdentity {
334 let liveness = LeaseOwnerLiveness::current_local_process(host_id.into())
335 .unwrap_or(LeaseOwnerLiveness::Opaque);
336 LeaseOwnerIdentity {
337 owner_id: owner_id.into(),
338 incarnation_id: incarnation_id.into(),
339 liveness,
340 }
341 }
342
343 pub fn same_incarnation(&self, other: &LeaseOwnerIdentity) -> bool {
344 self.owner_id == other.owner_id && self.incarnation_id == other.incarnation_id
345 }
346
347 pub fn is_definitely_dead_for_claimant(&self, claimant: &LeaseOwnerIdentity) -> bool {
348 self.liveness
349 .is_definitely_dead_for_claimant(&claimant.liveness)
350 }
351}
352
353#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
354#[serde(tag = "kind", rename_all = "snake_case")]
355pub enum LeaseOwnerLiveness {
356 LocalProcess {
357 host_id: String,
358 boot_id: String,
359 pid: u32,
360 process_start: String,
361 },
362 #[default]
363 Opaque,
364}
365
366impl LeaseOwnerLiveness {
367 pub fn current_local_process(host_id: impl Into<String>) -> Option<LeaseOwnerLiveness> {
368 let boot_id = std::fs::read_to_string(PROC_BOOT_ID_PATH)
369 .ok()
370 .map(|value| value.trim().to_string())
371 .filter(|value| !value.is_empty())?;
372 let pid = std::process::id();
373 let process_start = read_linux_process_start(pid)?;
374 Some(LeaseOwnerLiveness::LocalProcess {
375 host_id: host_id.into(),
376 boot_id,
377 pid,
378 process_start,
379 })
380 }
381
382 pub fn local_process_for_test(
383 host_id: impl Into<String>,
384 boot_id: impl Into<String>,
385 pid: u32,
386 process_start: impl Into<String>,
387 ) -> LeaseOwnerLiveness {
388 LeaseOwnerLiveness::LocalProcess {
389 host_id: host_id.into(),
390 boot_id: boot_id.into(),
391 pid,
392 process_start: process_start.into(),
393 }
394 }
395
396 pub fn is_definitely_dead_for_claimant(&self, claimant: &LeaseOwnerLiveness) -> bool {
397 let (
398 LeaseOwnerLiveness::LocalProcess {
399 host_id,
400 boot_id,
401 pid,
402 process_start,
403 },
404 LeaseOwnerLiveness::LocalProcess {
405 host_id: claimant_host_id,
406 boot_id: claimant_boot_id,
407 ..
408 },
409 ) = (self, claimant)
410 else {
411 return false;
412 };
413 if host_id != claimant_host_id || boot_id != claimant_boot_id {
414 return false;
415 }
416 matches!(linux_process_is_live(*pid, process_start), Some(false))
417 }
418}
419
420fn read_linux_process_start(pid: u32) -> Option<String> {
421 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
422 parse_linux_process_start(&stat)
423}
424
425fn linux_process_is_live(pid: u32, expected_process_start: &str) -> Option<bool> {
426 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
427 Ok(stat) => parse_linux_process_start(&stat).map(|start| start == expected_process_start),
428 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Some(false),
429 Err(_) => None,
430 }
431}
432
433fn parse_linux_process_start(stat: &str) -> Option<String> {
434 let after_comm = stat.rsplit_once(") ")?.1;
435 after_comm.split_whitespace().nth(19).map(ToOwned::to_owned)
436}
437
438#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
439pub struct SessionExecutionLease {
440 pub session_id: String,
441 pub owner: LeaseOwnerIdentity,
442 pub lease_token: String,
443 pub fencing_token: u64,
444 pub claimed_at_epoch_ms: u64,
445 pub expires_at_epoch_ms: u64,
446}
447
448#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
449pub struct SessionExecutionLeaseFence {
450 pub session_id: String,
451 pub owner: LeaseOwnerIdentity,
452 pub lease_token: String,
453 pub fencing_token: u64,
454}
455
456#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
457pub struct SessionExecutionLeaseCompletion {
458 pub session_id: String,
459 pub owner: LeaseOwnerIdentity,
460 pub lease_token: String,
461 pub fencing_token: u64,
462}
463
464impl SessionExecutionLease {
465 pub fn fence(&self) -> SessionExecutionLeaseFence {
466 SessionExecutionLeaseFence {
467 session_id: self.session_id.clone(),
468 owner: self.owner.clone(),
469 lease_token: self.lease_token.clone(),
470 fencing_token: self.fencing_token,
471 }
472 }
473
474 pub fn completion(&self) -> SessionExecutionLeaseCompletion {
475 SessionExecutionLeaseCompletion {
476 session_id: self.session_id.clone(),
477 owner: self.owner.clone(),
478 lease_token: self.lease_token.clone(),
479 fencing_token: self.fencing_token,
480 }
481 }
482}
483
484impl SessionExecutionLeaseCompletion {
485 pub fn from_lease(lease: &SessionExecutionLease) -> Self {
486 lease.completion()
487 }
488}
489
490#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
491pub enum SessionExecutionLeaseClaimOutcome {
492 Acquired(SessionExecutionLease),
493 Busy { holder: SessionExecutionLease },
494}
495
496impl SessionExecutionLeaseClaimOutcome {
497 pub fn acquired(self) -> Option<SessionExecutionLease> {
498 match self {
499 Self::Acquired(lease) => Some(lease),
500 Self::Busy { .. } => None,
501 }
502 }
503}
504
505#[derive(Clone, Debug)]
524pub struct AttachmentIntent {
525 pub attachment_id: crate::AttachmentId,
526 pub session_id: String,
527 pub canonical_uri: String,
532 pub intent_at_epoch_ms: u64,
533}
534
535#[derive(Clone, Debug)]
536pub struct AttachmentManifestEntry {
537 pub attachment_id: crate::AttachmentId,
538 pub session_id: String,
539 pub canonical_uri: String,
540 pub intent_at_epoch_ms: u64,
541 pub committed_at_epoch_ms: Option<u64>,
542}
543
544pub trait AttachmentManifest: Send + Sync {
556 fn record_intent(&self, intent: AttachmentIntent) -> Result<(), StoreError>;
557
558 fn commit_refs(
565 &self,
566 session_id: &str,
567 attachment_ids: &[crate::AttachmentId],
568 ) -> Result<(), StoreError>;
569
570 fn list_uncommitted(
575 &self,
576 older_than_epoch_ms: u64,
577 ) -> Result<Vec<AttachmentManifestEntry>, StoreError>;
578
579 fn forget(&self, attachment_id: &crate::AttachmentId) -> Result<(), StoreError>;
583}
584
585#[macro_export]
590macro_rules! impl_noop_attachment_manifest {
591 ($ty:ty) => {
592 impl $crate::AttachmentManifest for $ty {
593 fn record_intent(
594 &self,
595 _intent: $crate::AttachmentIntent,
596 ) -> ::std::result::Result<(), $crate::StoreError> {
597 Ok(())
598 }
599
600 fn commit_refs(
601 &self,
602 _session_id: &str,
603 _attachment_ids: &[$crate::AttachmentId],
604 ) -> ::std::result::Result<(), $crate::StoreError> {
605 Ok(())
606 }
607
608 fn list_uncommitted(
609 &self,
610 _older_than_epoch_ms: u64,
611 ) -> ::std::result::Result<Vec<$crate::AttachmentManifestEntry>, $crate::StoreError>
612 {
613 Ok(Vec::new())
614 }
615
616 fn forget(
617 &self,
618 _attachment_id: &$crate::AttachmentId,
619 ) -> ::std::result::Result<(), $crate::StoreError> {
620 Ok(())
621 }
622 }
623 };
624}
625
626pub fn ensure_supported_schema_version(
630 record_kind: &'static str,
631 actual: u32,
632 expected: u32,
633) -> Result<(), StoreError> {
634 if actual == expected {
635 Ok(())
636 } else {
637 Err(StoreError::UnsupportedRecordSchemaVersion {
638 record_kind,
639 actual,
640 expected,
641 })
642 }
643}
644
645#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
646pub struct RuntimeTurnCommitStamp {
647 pub session_id: String,
648 pub turn_id: String,
649 pub turn_commit_hash: String,
650}
651
652impl RuntimeTurnCommitStamp {
653 pub fn new(
654 session_id: impl Into<String>,
655 turn_id: impl Into<String>,
656 turn_commit_hash: impl Into<String>,
657 ) -> Self {
658 Self {
659 session_id: session_id.into(),
660 turn_id: turn_id.into(),
661 turn_commit_hash: turn_commit_hash.into(),
662 }
663 }
664}
665
666fn build_persisted_turn_state(state: &crate::RuntimeSessionState) -> crate::PersistedTurnState {
667 crate::PersistedTurnState {
668 turn_index: state.turn_index,
669 token_usage: state.token_usage.clone(),
670 last_prompt_usage: state.last_prompt_usage.clone(),
671 protocol_turn_options: state.protocol_turn_options.clone(),
672 }
673}
674
675fn build_checkpoint_from_persisted_state(
676 state: &crate::RuntimeSessionState,
677) -> HydratedSessionCheckpoint {
678 HydratedSessionCheckpoint {
679 turn_state: build_persisted_turn_state(state),
680 tool_state_ref: state.tool_state_ref.clone(),
681 tool_state: state.tool_state_snapshot.clone(),
682 plugin_snapshot_ref: state.plugin_snapshot_ref.clone(),
683 plugin_snapshot_revision: state.plugin_snapshot_revision,
684 plugin_snapshot: state.plugin_snapshot.clone(),
685 execution_state_ref: state.execution_state_ref.clone(),
686 execution_state: state.execution_state_snapshot.clone(),
687 }
688}
689
690impl RuntimeCommit {
691 pub fn turn_commit_hash(&self) -> Result<String, StoreError> {
692 let mut semantic_commit = self.clone();
693 semantic_commit.expected_head_revision = None;
694 semantic_commit.session_execution_lease = None;
695 semantic_commit.release_session_execution_lease = None;
696 semantic_commit.turn_commit = None;
697 let mut semantic_commit = serde_json::to_value(&semantic_commit).map_err(|err| {
698 StoreError::Backend(format!("failed to serialize runtime turn commit: {err}"))
699 })?;
700 scrub_turn_commit_hash_value(&mut semantic_commit);
701 crate::stable_hash::stable_json_sha256_hex(&semantic_commit).map_err(|err| {
702 StoreError::Backend(format!(
703 "failed to serialize runtime turn commit hash: {err}"
704 ))
705 })
706 }
707
708 pub fn persisted_state(
709 state: &crate::RuntimeSessionState,
710 usage_deltas: &[crate::TokenLedgerEntry],
711 ) -> Self {
712 Self {
713 session_id: state.session_id.clone(),
714 expected_head_revision: state.head_revision,
715 session_execution_lease: None,
716 release_session_execution_lease: None,
717 config: persisted_session_config_from_state(state),
718 agent_frames: state.agent_frames.clone(),
719 current_agent_frame_id: state.current_agent_frame_id.clone(),
720 graph: if state.graph_replace_required || state.head_revision.is_none() {
721 GraphCommitDelta::ReplaceFull(state.session_graph.clone())
722 } else {
723 GraphCommitDelta::Unchanged {
724 leaf_node_id: state.session_graph.leaf_node_id.clone(),
725 }
726 },
727 checkpoint: build_checkpoint_from_persisted_state(state),
728 usage_deltas: usage_deltas.to_vec(),
729 turn_commit: None,
730 completed_queue_claims: Vec::new(),
731 completed_turn_input_claims: Vec::new(),
732 interrupted_turn_input_turn_id: None,
733 committed_attachment_ids: Vec::new(),
734 }
735 }
736
737 pub(crate) fn persisted_state_with_graph_commit(
738 state: &crate::RuntimeSessionState,
739 graph: GraphCommitDelta,
740 usage_deltas: &[crate::TokenLedgerEntry],
741 ) -> Self {
742 Self {
743 session_id: state.session_id.clone(),
744 expected_head_revision: state.head_revision,
745 session_execution_lease: None,
746 release_session_execution_lease: None,
747 config: persisted_session_config_from_state(state),
748 agent_frames: state.agent_frames.clone(),
749 current_agent_frame_id: state.current_agent_frame_id.clone(),
750 graph,
751 checkpoint: build_checkpoint_from_persisted_state(state),
752 usage_deltas: usage_deltas.to_vec(),
753 turn_commit: None,
754 completed_queue_claims: Vec::new(),
755 completed_turn_input_claims: Vec::new(),
756 interrupted_turn_input_turn_id: None,
757 committed_attachment_ids: Vec::new(),
758 }
759 }
760
761 pub fn with_turn_commit(mut self, turn_commit: RuntimeTurnCommitStamp) -> Self {
762 self.turn_commit = Some(turn_commit);
763 self
764 }
765
766 pub fn with_session_execution_lease(mut self, lease: SessionExecutionLeaseFence) -> Self {
767 self.session_execution_lease = Some(lease);
768 self
769 }
770
771 pub fn releasing_session_execution_lease(
772 mut self,
773 completion: SessionExecutionLeaseCompletion,
774 ) -> Self {
775 self.release_session_execution_lease = Some(completion);
776 self
777 }
778
779 pub fn completing_queue_claim(
780 mut self,
781 completed_queue_claim: crate::QueuedWorkCompletion,
782 ) -> Self {
783 self.completed_queue_claims.push(completed_queue_claim);
784 self
785 }
786
787 pub fn completing_queue_claims(
788 mut self,
789 completed_queue_claims: impl IntoIterator<Item = crate::QueuedWorkCompletion>,
790 ) -> Self {
791 self.completed_queue_claims.extend(completed_queue_claims);
792 self
793 }
794
795 pub fn completing_turn_input_claim(
796 mut self,
797 completed_turn_input_claim: crate::TurnInputCompletion,
798 ) -> Self {
799 self.completed_turn_input_claims
800 .push(completed_turn_input_claim);
801 self
802 }
803
804 pub fn completing_turn_input_claims(
805 mut self,
806 completed_turn_input_claims: impl IntoIterator<Item = crate::TurnInputCompletion>,
807 ) -> Self {
808 self.completed_turn_input_claims
809 .extend(completed_turn_input_claims);
810 self
811 }
812
813 pub fn deferring_interrupted_turn_inputs(mut self, turn_id: impl Into<String>) -> Self {
814 self.interrupted_turn_input_turn_id = Some(turn_id.into());
815 self
816 }
817
818 pub fn with_committed_attachments(
819 mut self,
820 attachment_ids: impl IntoIterator<Item = crate::AttachmentId>,
821 ) -> Self {
822 self.committed_attachment_ids = attachment_ids.into_iter().collect();
823 self
824 }
825}
826
827fn scrub_turn_commit_hash_value(value: &mut serde_json::Value) {
828 match value {
829 serde_json::Value::Object(map) => {
830 let is_message = map.contains_key("role") && map.contains_key("parts");
831 let is_message_part = map.contains_key("kind")
832 && map.contains_key("content")
833 && map.contains_key("prune_state");
834 if is_message || is_message_part {
835 map.remove("id");
836 }
837 for volatile_key in ["node_id", "parent_node_id", "leaf_node_id", "timestamp"] {
838 map.remove(volatile_key);
839 }
840 for child in map.values_mut() {
841 scrub_turn_commit_hash_value(child);
842 }
843 }
844 serde_json::Value::Array(items) => {
845 for item in items {
846 scrub_turn_commit_hash_value(item);
847 }
848 }
849 _ => {}
850 }
851}
852
853fn persisted_session_state_from_head(
854 head: SessionHead,
855 checkpoint: Option<HydratedSessionCheckpoint>,
856) -> crate::RuntimeSessionState {
857 let mut state = crate::RuntimeSessionState {
858 session_id: head.session_id,
859 policy: crate::SessionPolicy::default(),
860 agent_frames: head.agent_frames,
861 current_agent_frame_id: head.current_agent_frame_id,
862 session_graph: head.graph,
863 turn_index: 0,
864 token_usage: crate::TokenUsage::default(),
865 last_prompt_usage: None,
866 protocol_turn_options: crate::ProtocolTurnOptions::default(),
867 tool_state_ref: None,
868 tool_state_generation: None,
869 tool_state_snapshot: None,
870 plugin_snapshot_ref: None,
871 plugin_snapshot_revision: None,
872 plugin_snapshot: None,
873 execution_state_ref: None,
874 execution_state_snapshot: None,
875 token_ledger: head.token_ledger,
876 checkpoint_ref: head.checkpoint_ref.clone(),
877 head_revision: Some(head.head_revision),
878 graph_replace_required: false,
879 };
880 state.policy.model = head.config.model.clone();
881 state.policy.provider_id = head.config.provider_id.clone();
882 if let Some(checkpoint) = checkpoint {
883 state.turn_index = checkpoint.turn_state.turn_index;
884 state.token_usage = checkpoint.turn_state.token_usage;
885 state.last_prompt_usage = checkpoint.turn_state.last_prompt_usage;
886 state.protocol_turn_options = checkpoint.turn_state.protocol_turn_options;
887 state.tool_state_ref = checkpoint.tool_state_ref.clone();
888 state.tool_state_generation = checkpoint
889 .tool_state
890 .as_ref()
891 .map(|snapshot| snapshot.generation());
892 state.tool_state_snapshot = checkpoint.tool_state;
893 state.plugin_snapshot_ref = checkpoint.plugin_snapshot_ref.clone();
894 state.plugin_snapshot_revision = checkpoint.plugin_snapshot_revision;
895 state.plugin_snapshot = checkpoint.plugin_snapshot;
896 state.execution_state_ref = checkpoint.execution_state_ref.clone();
897 state.execution_state_snapshot = checkpoint.execution_state;
898 }
899 state.ensure_agent_frame_initialized();
900 state
901}
902
903impl Default for SessionHead {
904 fn default() -> Self {
905 Self {
906 session_id: default_root_session_id(),
907 head_revision: 0,
908 agent_frames: Vec::new(),
909 current_agent_frame_id: String::new(),
910 graph: crate::SessionGraph::default(),
911 config: crate::PersistedSessionConfig::default(),
912 checkpoint_ref: None,
913 token_ledger: Vec::new(),
914 }
915 }
916}
917
918impl Default for SessionHeadMeta {
919 fn default() -> Self {
920 Self {
921 session_id: default_root_session_id(),
922 head_revision: 0,
923 config: crate::PersistedSessionConfig::default(),
924 agent_frames: Vec::new(),
925 current_agent_frame_id: String::new(),
926 checkpoint_ref: None,
927 leaf_node_id: None,
928 graph_node_count: 0,
929 token_ledger: Vec::new(),
930 }
931 }
932}
933
934#[async_trait::async_trait]
949pub trait RuntimePersistence: AttachmentManifest + Send + Sync {
950 fn durability_tier(&self) -> crate::DurabilityTier {
953 crate::DurabilityTier::Inline
954 }
955
956 async fn load_session(
957 &self,
958 scope: SessionReadScope,
959 ) -> Result<Option<PersistedSessionRead>, StoreError>;
960
961 async fn load_node(
962 &self,
963 node_id: &str,
964 ) -> Result<Option<crate::SessionNodeRecord>, StoreError>;
965
966 async fn commit_runtime_state(
967 &self,
968 commit: RuntimeCommit,
969 ) -> Result<RuntimeCommitResult, StoreError>;
970
971 async fn enqueue_pending_turn_input(
977 &self,
978 _input: crate::PendingTurnInputDraft,
979 ) -> Result<crate::PendingTurnInput, StoreError> {
980 Err(StoreError::Backend(
981 "pending turn input is not supported by this test store".to_string(),
982 ))
983 }
984
985 async fn list_pending_turn_inputs(
990 &self,
991 _session_id: &str,
992 ) -> Result<Vec<crate::PendingTurnInput>, StoreError> {
993 Ok(Vec::new())
994 }
995
996 async fn cancel_pending_turn_input(
998 &self,
999 _session_id: &str,
1000 _input_id: &str,
1001 ) -> Result<Option<crate::PendingTurnInput>, StoreError> {
1002 Ok(None)
1003 }
1004
1005 async fn claim_active_turn_inputs(
1007 &self,
1008 session_id: &str,
1009 _session_execution_lease: &SessionExecutionLeaseFence,
1010 _owner: &LeaseOwnerIdentity,
1011 _turn_id: &str,
1012 _checkpoint: crate::CheckpointKind,
1013 _lease_ttl_ms: u64,
1014 _max_inputs: usize,
1015 ) -> Result<Option<crate::TurnInputClaim>, StoreError> {
1016 Err(StoreError::Backend(format!(
1017 "pending turn input is not supported for session `{session_id}` by this test store"
1018 )))
1019 }
1020
1021 async fn claim_next_turn_inputs(
1023 &self,
1024 session_id: &str,
1025 _session_execution_lease: &SessionExecutionLeaseFence,
1026 _owner: &LeaseOwnerIdentity,
1027 _lease_ttl_ms: u64,
1028 _max_inputs: usize,
1029 ) -> Result<Option<crate::TurnInputClaim>, StoreError> {
1030 Err(StoreError::Backend(format!(
1031 "pending turn input is not supported for session `{session_id}` by this test store"
1032 )))
1033 }
1034
1035 async fn abandon_turn_input_claim(
1037 &self,
1038 _claim: &crate::TurnInputClaim,
1039 ) -> Result<(), StoreError> {
1040 Ok(())
1041 }
1042
1043 async fn try_claim_session_execution_lease(
1050 &self,
1051 session_id: &str,
1052 owner: &LeaseOwnerIdentity,
1053 lease_ttl_ms: u64,
1054 ) -> Result<SessionExecutionLeaseClaimOutcome, StoreError>;
1055
1056 async fn reclaim_session_execution_lease(
1062 &self,
1063 session_id: &str,
1064 owner: &LeaseOwnerIdentity,
1065 observed_holder: &SessionExecutionLeaseFence,
1066 lease_ttl_ms: u64,
1067 ) -> Result<SessionExecutionLeaseClaimOutcome, StoreError>;
1068
1069 async fn renew_session_execution_lease(
1074 &self,
1075 fence: &SessionExecutionLeaseFence,
1076 lease_ttl_ms: u64,
1077 ) -> Result<SessionExecutionLease, StoreError>;
1078
1079 async fn release_session_execution_lease(
1083 &self,
1084 completion: &SessionExecutionLeaseCompletion,
1085 ) -> Result<(), StoreError>;
1086
1087 async fn enqueue_queued_work(
1093 &self,
1094 _batch: crate::QueuedWorkBatchDraft,
1095 ) -> Result<crate::QueuedWorkBatch, StoreError> {
1096 Err(StoreError::Backend(
1097 "queued work is not supported by this test store".to_string(),
1098 ))
1099 }
1100
1101 async fn claim_leading_ready_session_command(
1109 &self,
1110 session_id: &str,
1111 _session_execution_lease: &SessionExecutionLeaseFence,
1112 _owner: &LeaseOwnerIdentity,
1113 _lease_ttl_ms: u64,
1114 ) -> Result<Option<crate::QueuedWorkClaim>, StoreError> {
1115 Err(StoreError::Backend(format!(
1116 "queued work is not supported for session `{session_id}` by this test store"
1117 )))
1118 }
1119
1120 async fn claim_ready_queued_work(
1129 &self,
1130 session_id: &str,
1131 _session_execution_lease: &SessionExecutionLeaseFence,
1132 _owner: &LeaseOwnerIdentity,
1133 _boundary: crate::QueuedWorkClaimBoundary,
1134 _lease_ttl_ms: u64,
1135 _max_batches: usize,
1136 ) -> Result<Option<crate::QueuedWorkClaim>, StoreError> {
1137 Err(StoreError::Backend(format!(
1138 "queued work is not supported for session `{session_id}` by this test store"
1139 )))
1140 }
1141
1142 async fn claim_ready_queued_work_by_batch_ids(
1151 &self,
1152 session_id: &str,
1153 session_execution_lease: &SessionExecutionLeaseFence,
1154 owner: &LeaseOwnerIdentity,
1155 boundary: crate::QueuedWorkClaimBoundary,
1156 lease_ttl_ms: u64,
1157 batch_ids: &[String],
1158 ) -> Result<Option<crate::QueuedWorkClaim>, StoreError> {
1159 if batch_ids.is_empty() {
1160 return Ok(None);
1161 }
1162 let Some(claim) = self
1163 .claim_ready_queued_work(
1164 session_id,
1165 session_execution_lease,
1166 owner,
1167 boundary,
1168 lease_ttl_ms,
1169 batch_ids.len(),
1170 )
1171 .await?
1172 else {
1173 return Ok(None);
1174 };
1175 let claimed_ids = claim
1176 .batches
1177 .iter()
1178 .map(|batch| batch.batch_id.as_str())
1179 .collect::<Vec<_>>();
1180 if claimed_ids == batch_ids.iter().map(String::as_str).collect::<Vec<_>>() {
1181 return Ok(Some(claim));
1182 }
1183 self.abandon_queued_work_claim(&claim).await?;
1184 Ok(None)
1185 }
1186
1187 async fn renew_queued_work_claim(
1192 &self,
1193 claim: &crate::QueuedWorkClaim,
1194 _lease_ttl_ms: u64,
1195 ) -> Result<crate::QueuedWorkClaim, StoreError> {
1196 Err(StoreError::QueuedWorkClaimExpired {
1197 session_id: claim.session_id.clone(),
1198 claim_id: claim.claim_id.clone(),
1199 })
1200 }
1201
1202 async fn abandon_queued_work_claim(
1207 &self,
1208 _claim: &crate::QueuedWorkClaim,
1209 ) -> Result<(), StoreError> {
1210 Ok(())
1211 }
1212
1213 async fn cancel_queued_work_batch(
1223 &self,
1224 _session_id: &str,
1225 _batch_id: &str,
1226 ) -> Result<Option<crate::QueuedWorkBatch>, StoreError> {
1227 Ok(None)
1228 }
1229
1230 async fn list_queued_work(
1234 &self,
1235 _session_id: &str,
1236 ) -> Result<Vec<crate::QueuedWorkBatch>, StoreError> {
1237 Ok(Vec::new())
1238 }
1239
1240 async fn list_pending_queued_work(
1245 &self,
1246 session_id: &str,
1247 ) -> Result<Vec<crate::QueuedWorkBatch>, StoreError> {
1248 self.list_queued_work(session_id).await
1249 }
1250
1251 async fn save_session_meta(&self, meta: SessionMeta) -> Result<(), StoreError>;
1252 async fn load_session_meta(&self) -> Result<Option<SessionMeta>, StoreError>;
1253
1254 async fn tombstone_nodes(&self, ids: &[String]) -> Result<(), StoreError>;
1255 async fn vacuum(&self) -> Result<VacuumReport, StoreError>;
1256 async fn gc_unreachable(&self) -> Result<GcReport, StoreError>;
1257}
1258
1259fn persisted_session_state_from_read(read: PersistedSessionRead) -> crate::RuntimeSessionState {
1260 persisted_session_state_from_head(
1261 SessionHead {
1262 session_id: read.session_id,
1263 head_revision: read.head_revision,
1264 agent_frames: read.agent_frames,
1265 current_agent_frame_id: read.current_agent_frame_id,
1266 graph: read.graph,
1267 config: read.config,
1268 checkpoint_ref: read.checkpoint_ref,
1269 token_ledger: read.token_ledger,
1270 },
1271 read.checkpoint,
1272 )
1273}
1274
1275pub async fn load_persisted_session_state(
1276 store: &(dyn RuntimePersistence + '_),
1277) -> Result<Option<crate::RuntimeSessionState>, StoreError> {
1278 Ok(store
1279 .load_session(SessionReadScope::FullGraph)
1280 .await?
1281 .map(persisted_session_state_from_read))
1282}
1283
1284pub async fn load_persisted_session_state_active_path(
1285 store: &(dyn RuntimePersistence + '_),
1286 leaf_node_id: Option<String>,
1287) -> Result<Option<crate::RuntimeSessionState>, StoreError> {
1288 Ok(store
1289 .load_session(SessionReadScope::ActivePath { leaf_node_id })
1290 .await?
1291 .map(persisted_session_state_from_read))
1292}
1293
1294pub async fn refresh_persisted_session_state(
1295 store: &(dyn RuntimePersistence + '_),
1296 state: &mut crate::RuntimeSessionState,
1297) -> Result<(), StoreError> {
1298 if let Some(mut fresh) = load_persisted_session_state(store).await? {
1299 fresh.policy.session_id = state.policy.session_id.clone();
1300 fresh.policy.max_turns = state.policy.max_turns;
1301 *state = fresh;
1302 }
1303 Ok(())
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::{LeaseOwnerIdentity, LeaseOwnerLiveness};
1309
1310 fn local_liveness(
1311 host_id: &str,
1312 boot_id: &str,
1313 pid: u32,
1314 process_start: &str,
1315 ) -> LeaseOwnerLiveness {
1316 LeaseOwnerLiveness::local_process_for_test(host_id, boot_id, pid, process_start)
1317 }
1318
1319 #[test]
1320 fn lease_owner_identity_requires_same_incarnation() {
1321 let first = LeaseOwnerIdentity::opaque("owner", "incarnation-a");
1322 let same = LeaseOwnerIdentity::opaque("owner", "incarnation-a");
1323 let next = LeaseOwnerIdentity::opaque("owner", "incarnation-b");
1324
1325 assert!(first.same_incarnation(&same));
1326 assert!(!first.same_incarnation(&next));
1327 }
1328
1329 #[test]
1330 fn local_liveness_only_proves_same_host_boot_dead_processes() {
1331 let holder = local_liveness(
1332 "host-a",
1333 "boot-a",
1334 std::process::id(),
1335 "not-the-current-process-start",
1336 );
1337 let same_host_boot = local_liveness("host-a", "boot-a", std::process::id(), "claimant");
1338 let other_host = local_liveness("host-b", "boot-a", std::process::id(), "claimant");
1339 let other_boot = local_liveness("host-a", "boot-b", std::process::id(), "claimant");
1340
1341 assert!(holder.is_definitely_dead_for_claimant(&same_host_boot));
1342 assert!(!holder.is_definitely_dead_for_claimant(&other_host));
1343 assert!(!holder.is_definitely_dead_for_claimant(&other_boot));
1344 assert!(!holder.is_definitely_dead_for_claimant(&LeaseOwnerLiveness::Opaque));
1345 assert!(!LeaseOwnerLiveness::Opaque.is_definitely_dead_for_claimant(&same_host_boot));
1346 }
1347}