1mod attachment_manifest;
4mod lease_timings;
5pub mod queued_work;
6
7pub use attachment_manifest::{AttachmentIntent, AttachmentManifest, AttachmentManifestEntry};
8pub use lease_timings::{LeaseTimings, LeaseTimingsError};
9
10const PROC_BOOT_ID_PATH: &str = "/proc/sys/kernel/random/boot_id";
11
12fn default_root_session_id() -> String {
13 "root".to_string()
14}
15
16pub const SESSION_HEAD_META_SCHEMA_VERSION: u32 = 1;
17pub const SESSION_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
18
19#[cfg(test)]
20mod persisted_state_tests {
21 use super::*;
22
23 #[test]
24 fn persisted_state_hydrates_provider_id_without_live_provider_rebinding() {
25 let state = persisted_session_state_from_head(
26 SessionHead {
27 session_id: "stored".to_string(),
28 head_revision: 7,
29 agent_frames: Vec::new(),
30 current_agent_frame_id: String::new(),
31 graph: crate::SessionGraph::default(),
32 config: crate::PersistedSessionConfig {
33 provider_id: "stored-provider".to_string(),
34 model: crate::ModelSpec::default(),
35 },
36 checkpoint_ref: None,
37 token_ledger: Vec::new(),
38 },
39 None,
40 );
41
42 assert_eq!(state.policy.recorded_provider_id(), "stored-provider");
43 assert!(
44 state
45 .agent_frames
46 .iter()
47 .all(|frame| frame.assignment.policy.recorded_provider_id() == "stored-provider")
48 );
49 assert_eq!(state.head_revision, Some(7));
50 }
51
52 #[test]
53 fn versioned_json_record_rejects_missing_schema_version() {
54 let err = decode_versioned_json_record::<SessionHeadMeta>(
55 "{}",
56 "SessionHeadMeta",
57 SESSION_HEAD_META_SCHEMA_VERSION,
58 )
59 .expect_err("pre-versioned session head should fail");
60
61 assert!(matches!(
62 err,
63 StoreError::MissingRecordSchemaVersion {
64 record_kind: "SessionHeadMeta",
65 expected: SESSION_HEAD_META_SCHEMA_VERSION
66 }
67 ));
68 }
69
70 #[test]
71 fn versioned_json_record_rejects_invalid_schema_version() {
72 let err = decode_versioned_json_record::<SessionHeadMeta>(
73 r#"{"schema_version":"1"}"#,
74 "SessionHeadMeta",
75 SESSION_HEAD_META_SCHEMA_VERSION,
76 )
77 .expect_err("invalid session head schema version should fail");
78
79 assert!(matches!(
80 err,
81 StoreError::InvalidRecordSchemaVersion {
82 record_kind: "SessionHeadMeta",
83 expected: SESSION_HEAD_META_SCHEMA_VERSION,
84 ..
85 }
86 ));
87 }
88
89 #[test]
90 fn versioned_json_record_rejects_unsupported_schema_version() {
91 let err = decode_versioned_json_record::<SessionHeadMeta>(
92 r#"{"schema_version":2}"#,
93 "SessionHeadMeta",
94 SESSION_HEAD_META_SCHEMA_VERSION,
95 )
96 .expect_err("unsupported session head schema version should fail");
97
98 assert!(matches!(
99 err,
100 StoreError::UnsupportedRecordSchemaVersion {
101 record_kind: "SessionHeadMeta",
102 actual: 2,
103 expected: SESSION_HEAD_META_SCHEMA_VERSION
104 }
105 ));
106 }
107}
108
109#[derive(Debug, thiserror::Error)]
110pub enum StoreError {
111 #[error(
112 "store is already bound to session `{bound_session_id}` and cannot be reused for `{attempted_session_id}`"
113 )]
114 SessionBindingMismatch {
115 bound_session_id: String,
116 attempted_session_id: String,
117 },
118 #[error("store does not support read scope {0:?}")]
119 UnsupportedReadScope(SessionReadScope),
120 #[error("store head revision conflict: expected {expected:?}, actual {actual}")]
121 HeadRevisionConflict { expected: Option<u64>, actual: u64 },
122 #[error(
123 "runtime turn `{turn_id}` for session `{session_id}` was already committed with a different commit hash"
124 )]
125 RuntimeTurnCommitConflict { session_id: String, turn_id: String },
126 #[error(
127 "queued work claim `{claim_id}` for session `{session_id}` is superseded by a newer session-lease generation"
128 )]
129 QueuedWorkClaimSuperseded {
130 session_id: String,
131 claim_id: String,
132 },
133 #[error(
134 "turn input claim `{claim_id}` for session `{session_id}` is superseded by a newer session-lease generation"
135 )]
136 TurnInputClaimSuperseded {
137 session_id: String,
138 claim_id: String,
139 },
140 #[error(
141 "runtime commit for session `{session_id}` includes queued-work-derived content without settling claim `{claim_id}`"
142 )]
143 UnsettledQueuedWorkClaim {
144 session_id: String,
145 claim_id: String,
146 },
147 #[error(
148 "runtime commit for session `{session_id}` includes turn-input-derived content without settling claim `{claim_id}`"
149 )]
150 UnsettledTurnInputClaim {
151 session_id: String,
152 claim_id: String,
153 },
154 #[error(
155 "pending turn input source_key `{source_key}` for session `{session_id}` is already bound to input `{existing_input_id}` with different submitted content"
156 )]
157 PendingTurnInputSourceKeyConflict {
158 session_id: String,
159 source_key: String,
160 existing_input_id: String,
161 },
162 #[error("session execution lease for session `{session_id}` is missing or expired")]
163 SessionExecutionLeaseExpired { session_id: String },
164 #[error(
165 "{record_kind} schema_version {actual} is not supported by this binary (expected {expected})"
166 )]
167 UnsupportedRecordSchemaVersion {
168 record_kind: &'static str,
169 actual: u32,
170 expected: u32,
171 },
172 #[error(
173 "{record_kind} is missing schema_version and was written by unsupported pre-versioned state (expected {expected})"
174 )]
175 MissingRecordSchemaVersion {
176 record_kind: &'static str,
177 expected: u32,
178 },
179 #[error("{record_kind} schema_version {actual} is invalid (expected integer {expected})")]
180 InvalidRecordSchemaVersion {
181 record_kind: &'static str,
182 actual: String,
183 expected: u32,
184 },
185 #[error("store backend error: {0}")]
186 Backend(String),
187}
188
189#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
190pub struct SessionMeta {
191 pub session_id: String,
192 pub session_name: String,
193 pub created_at: String,
194 pub model: String,
195 pub cwd: Option<String>,
196 pub relation: crate::SessionRelation,
197}
198
199impl SessionMeta {
200 pub fn parent_session_id(&self) -> Option<&str> {
203 self.relation.parent_session_id()
204 }
205}
206
207#[derive(Clone, Debug)]
209pub struct SessionPickerInfo {
210 pub session_id: String,
211 pub cwd: Option<String>,
212 pub relation: crate::SessionRelation,
213 pub first_user_message: String,
214 pub user_message_count: usize,
215}
216
217impl SessionPickerInfo {
218 pub fn parent_session_id(&self) -> Option<&str> {
219 self.relation.parent_session_id()
220 }
221}
222
223#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
224#[serde(transparent)]
225pub struct BlobRef(pub String);
226
227impl BlobRef {
228 pub fn as_str(&self) -> &str {
229 &self.0
230 }
231}
232
233impl std::fmt::Display for BlobRef {
234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235 f.write_str(&self.0)
236 }
237}
238
239impl From<String> for BlobRef {
240 fn from(value: String) -> Self {
241 Self(value)
242 }
243}
244
245#[derive(Clone, Debug, Default, PartialEq, Eq)]
246pub struct GcReport {
247 pub root_count: usize,
248 pub retained_blob_count: usize,
249 pub deleted_blob_count: usize,
250}
251
252#[derive(Clone, Debug, Default, PartialEq, Eq)]
258pub struct VacuumReport {
259 pub removed_node_count: usize,
260 pub removed_pending_turn_input_tombstone_count: usize,
261}
262
263#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
264pub struct SessionCheckpoint {
265 pub schema_version: u32,
266 pub turn_state: crate::PersistedTurnState,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub tool_state_ref: Option<BlobRef>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub plugin_snapshot_ref: Option<BlobRef>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub plugin_snapshot_revision: Option<u64>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub execution_state_ref: Option<BlobRef>,
275}
276
277impl Default for SessionCheckpoint {
278 fn default() -> Self {
279 Self {
280 schema_version: SESSION_CHECKPOINT_SCHEMA_VERSION,
281 turn_state: crate::PersistedTurnState::default(),
282 tool_state_ref: None,
283 plugin_snapshot_ref: None,
284 plugin_snapshot_revision: None,
285 execution_state_ref: None,
286 }
287 }
288}
289
290impl SessionCheckpoint {
291 pub fn new(
292 turn_state: crate::PersistedTurnState,
293 tool_state_ref: Option<BlobRef>,
294 plugin_snapshot_ref: Option<BlobRef>,
295 plugin_snapshot_revision: Option<u64>,
296 execution_state_ref: Option<BlobRef>,
297 ) -> Self {
298 Self {
299 schema_version: SESSION_CHECKPOINT_SCHEMA_VERSION,
300 turn_state,
301 tool_state_ref,
302 plugin_snapshot_ref,
303 plugin_snapshot_revision,
304 execution_state_ref,
305 }
306 }
307}
308
309#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
310pub struct HydratedSessionCheckpoint {
311 pub turn_state: crate::PersistedTurnState,
312 pub tool_state_ref: Option<BlobRef>,
313 pub tool_state: Option<crate::ToolState>,
314 pub plugin_snapshot_ref: Option<BlobRef>,
315 pub plugin_snapshot: Option<crate::PluginSessionSnapshot>,
316 pub plugin_snapshot_revision: Option<u64>,
317 pub execution_state_ref: Option<BlobRef>,
318 pub execution_state: Option<Vec<u8>>,
319}
320
321#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
322pub struct SessionHead {
323 #[serde(default = "default_root_session_id")]
324 pub session_id: String,
325 #[serde(default)]
326 pub head_revision: u64,
327 #[serde(default)]
328 pub agent_frames: Vec<crate::AgentFrameRecord>,
329 #[serde(default, skip_serializing_if = "String::is_empty")]
330 pub current_agent_frame_id: crate::AgentFrameId,
331 pub graph: crate::SessionGraph,
332 pub config: crate::PersistedSessionConfig,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub checkpoint_ref: Option<BlobRef>,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub token_ledger: Vec<crate::TokenLedgerEntry>,
337}
338
339#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
340pub struct SessionHeadMeta {
341 pub schema_version: u32,
342 #[serde(default = "default_root_session_id")]
343 pub session_id: String,
344 #[serde(default)]
345 pub head_revision: u64,
346 pub config: crate::PersistedSessionConfig,
347 #[serde(default)]
348 pub agent_frames: Vec<crate::AgentFrameRecord>,
349 #[serde(default, skip_serializing_if = "String::is_empty")]
350 pub current_agent_frame_id: crate::AgentFrameId,
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub checkpoint_ref: Option<BlobRef>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub leaf_node_id: Option<String>,
355 #[serde(default)]
356 pub graph_node_count: usize,
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub token_ledger: Vec<crate::TokenLedgerEntry>,
359}
360
361fn persisted_session_config_from_state(
362 state: &crate::RuntimeSessionState,
363) -> crate::PersistedSessionConfig {
364 crate::PersistedSessionConfig {
365 provider_id: state.policy.recorded_provider_id().to_string(),
366 model: state.policy.model.clone(),
367 }
368}
369
370#[derive(Clone, Debug, PartialEq, Eq)]
371pub enum SessionReadScope {
372 FullGraph,
373 ActivePath { leaf_node_id: Option<String> },
374}
375
376#[derive(Clone, Debug)]
377pub struct PersistedSessionRead {
378 pub session_id: String,
379 pub head_revision: u64,
380 pub config: crate::PersistedSessionConfig,
381 pub agent_frames: Vec<crate::AgentFrameRecord>,
382 pub current_agent_frame_id: crate::AgentFrameId,
383 pub graph: crate::SessionGraph,
384 pub checkpoint_ref: Option<BlobRef>,
385 pub checkpoint: Option<HydratedSessionCheckpoint>,
386 pub token_ledger: Vec<crate::TokenLedgerEntry>,
387}
388
389#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
390pub enum GraphCommitDelta {
391 Unchanged {
392 leaf_node_id: Option<String>,
393 },
394 Append {
395 nodes: Vec<crate::SessionNodeRecord>,
396 leaf_node_id: Option<String>,
397 },
398 ReplaceFull(crate::SessionGraph),
399}
400
401impl GraphCommitDelta {
402 pub fn leaf_node_id(&self) -> Option<&String> {
403 match self {
404 Self::Unchanged { leaf_node_id } | Self::Append { leaf_node_id, .. } => {
405 leaf_node_id.as_ref()
406 }
407 Self::ReplaceFull(graph) => graph.leaf_node_id.as_ref(),
408 }
409 }
410}
411
412#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
413pub struct RuntimeCommit {
414 pub session_id: String,
415 pub expected_head_revision: Option<u64>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub session_execution_lease: Option<SessionExecutionLeaseFence>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub release_session_execution_lease: Option<SessionExecutionLeaseCompletion>,
420 pub config: crate::PersistedSessionConfig,
421 pub agent_frames: Vec<crate::AgentFrameRecord>,
422 pub current_agent_frame_id: crate::AgentFrameId,
423 pub graph: GraphCommitDelta,
424 pub checkpoint: HydratedSessionCheckpoint,
425 pub usage_deltas: Vec<crate::TokenLedgerEntry>,
426 pub turn_commit: Option<RuntimeTurnCommitStamp>,
427 pub completed_queue_claims: Vec<crate::QueuedWorkCompletion>,
428 pub completed_turn_input_claims: Vec<crate::TurnInputCompletion>,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
430 pub enqueued_queue_batches: Vec<crate::QueuedWorkBatchDraft>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub interrupted_turn_input_turn_id: Option<String>,
433 pub committed_attachment_ids: Vec<crate::AttachmentId>,
441}
442
443#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
444pub struct RuntimeCommitResult {
445 pub head_revision: u64,
446 pub checkpoint_ref: BlobRef,
447 pub manifest: SessionCheckpoint,
448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub enqueued_queue_batches: Vec<crate::QueuedWorkBatch>,
450}
451
452#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
453pub struct LeaseOwnerIdentity {
454 pub owner_id: String,
455 pub incarnation_id: String,
456 #[serde(default)]
457 pub liveness: LeaseOwnerLiveness,
458}
459
460impl LeaseOwnerIdentity {
461 pub fn opaque(
462 owner_id: impl Into<String>,
463 incarnation_id: impl Into<String>,
464 ) -> LeaseOwnerIdentity {
465 LeaseOwnerIdentity {
466 owner_id: owner_id.into(),
467 incarnation_id: incarnation_id.into(),
468 liveness: LeaseOwnerLiveness::Opaque,
469 }
470 }
471
472 pub fn local_process(
473 owner_id: impl Into<String>,
474 incarnation_id: impl Into<String>,
475 host_id: impl Into<String>,
476 ) -> LeaseOwnerIdentity {
477 let liveness = LeaseOwnerLiveness::current_local_process(host_id.into())
478 .unwrap_or(LeaseOwnerLiveness::Opaque);
479 LeaseOwnerIdentity {
480 owner_id: owner_id.into(),
481 incarnation_id: incarnation_id.into(),
482 liveness,
483 }
484 }
485
486 pub fn same_incarnation(&self, other: &LeaseOwnerIdentity) -> bool {
487 self.owner_id == other.owner_id && self.incarnation_id == other.incarnation_id
488 }
489
490 pub fn is_definitely_dead_for_claimant(&self, claimant: &LeaseOwnerIdentity) -> bool {
491 self.liveness
492 .is_definitely_dead_for_claimant(&claimant.liveness)
493 }
494}
495
496#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
497#[serde(tag = "kind", rename_all = "snake_case")]
498pub enum LeaseOwnerLiveness {
499 LocalProcess {
500 host_id: String,
501 boot_id: String,
502 pid: u32,
503 process_start: String,
504 },
505 #[default]
506 Opaque,
507}
508
509impl LeaseOwnerLiveness {
510 pub fn current_local_process(host_id: impl Into<String>) -> Option<LeaseOwnerLiveness> {
511 let boot_id = std::fs::read_to_string(PROC_BOOT_ID_PATH)
512 .ok()
513 .map(|value| value.trim().to_string())
514 .filter(|value| !value.is_empty())?;
515 let pid = std::process::id();
516 let process_start = read_linux_process_start(pid)?;
517 Some(LeaseOwnerLiveness::LocalProcess {
518 host_id: host_id.into(),
519 boot_id,
520 pid,
521 process_start,
522 })
523 }
524
525 pub fn local_process_for_test(
526 host_id: impl Into<String>,
527 boot_id: impl Into<String>,
528 pid: u32,
529 process_start: impl Into<String>,
530 ) -> LeaseOwnerLiveness {
531 LeaseOwnerLiveness::LocalProcess {
532 host_id: host_id.into(),
533 boot_id: boot_id.into(),
534 pid,
535 process_start: process_start.into(),
536 }
537 }
538
539 pub fn is_definitely_dead_for_claimant(&self, claimant: &LeaseOwnerLiveness) -> bool {
540 let (
541 LeaseOwnerLiveness::LocalProcess {
542 host_id,
543 boot_id,
544 pid,
545 process_start,
546 },
547 LeaseOwnerLiveness::LocalProcess {
548 host_id: claimant_host_id,
549 boot_id: claimant_boot_id,
550 ..
551 },
552 ) = (self, claimant)
553 else {
554 return false;
555 };
556 if host_id != claimant_host_id || boot_id != claimant_boot_id {
557 return false;
558 }
559 matches!(linux_process_is_live(*pid, process_start), Some(false))
560 }
561}
562
563fn read_linux_process_start(pid: u32) -> Option<String> {
564 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
565 parse_linux_process_start(&stat)
566}
567
568fn linux_process_is_live(pid: u32, expected_process_start: &str) -> Option<bool> {
569 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
570 Ok(stat) => parse_linux_process_start(&stat).map(|start| start == expected_process_start),
571 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Some(false),
572 Err(_) => None,
573 }
574}
575
576fn parse_linux_process_start(stat: &str) -> Option<String> {
577 let after_comm = stat.rsplit_once(") ")?.1;
578 after_comm.split_whitespace().nth(19).map(ToOwned::to_owned)
579}
580
581#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
582pub struct SessionExecutionLease {
583 pub session_id: String,
584 pub owner: LeaseOwnerIdentity,
585 pub lease_token: String,
586 pub fencing_token: u64,
587 pub claimed_at_epoch_ms: u64,
588 pub expires_at_epoch_ms: u64,
589}
590
591#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
592pub struct SessionExecutionLeaseFence {
593 pub session_id: String,
594 pub owner: LeaseOwnerIdentity,
595 pub lease_token: String,
596 pub fencing_token: u64,
597}
598
599#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
600pub struct SessionExecutionLeaseCompletion {
601 pub session_id: String,
602 pub owner: LeaseOwnerIdentity,
603 pub lease_token: String,
604 pub fencing_token: u64,
605}
606
607impl SessionExecutionLease {
608 pub fn fence(&self) -> SessionExecutionLeaseFence {
609 SessionExecutionLeaseFence {
610 session_id: self.session_id.clone(),
611 owner: self.owner.clone(),
612 lease_token: self.lease_token.clone(),
613 fencing_token: self.fencing_token,
614 }
615 }
616
617 pub fn completion(&self) -> SessionExecutionLeaseCompletion {
618 SessionExecutionLeaseCompletion {
619 session_id: self.session_id.clone(),
620 owner: self.owner.clone(),
621 lease_token: self.lease_token.clone(),
622 fencing_token: self.fencing_token,
623 }
624 }
625}
626
627impl SessionExecutionLeaseCompletion {
628 pub fn from_lease(lease: &SessionExecutionLease) -> Self {
629 lease.completion()
630 }
631}
632
633#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
634pub enum SessionExecutionLeaseClaimOutcome {
635 Acquired(SessionExecutionLease),
636 Busy { holder: SessionExecutionLease },
637}
638
639impl SessionExecutionLeaseClaimOutcome {
640 pub fn acquired(self) -> Option<SessionExecutionLease> {
641 match self {
642 Self::Acquired(lease) => Some(lease),
643 Self::Busy { .. } => None,
644 }
645 }
646}
647
648pub fn ensure_supported_schema_version(
652 record_kind: &'static str,
653 actual: u32,
654 expected: u32,
655) -> Result<(), StoreError> {
656 if actual == expected {
657 Ok(())
658 } else {
659 Err(StoreError::UnsupportedRecordSchemaVersion {
660 record_kind,
661 actual,
662 expected,
663 })
664 }
665}
666
667pub fn ensure_supported_record_schema_version(
668 record_kind: &'static str,
669 value: &serde_json::Value,
670 expected: u32,
671) -> Result<(), StoreError> {
672 let Some(schema_version) = value.get("schema_version") else {
673 return Err(StoreError::MissingRecordSchemaVersion {
674 record_kind,
675 expected,
676 });
677 };
678 let Some(actual) = schema_version
679 .as_u64()
680 .and_then(|version| u32::try_from(version).ok())
681 else {
682 return Err(StoreError::InvalidRecordSchemaVersion {
683 record_kind,
684 actual: schema_version.to_string(),
685 expected,
686 });
687 };
688 ensure_supported_schema_version(record_kind, actual, expected)
689}
690
691pub fn decode_versioned_json_record<T>(
692 json: &str,
693 record_kind: &'static str,
694 expected: u32,
695) -> Result<T, StoreError>
696where
697 T: serde::de::DeserializeOwned,
698{
699 let value: serde_json::Value = serde_json::from_str(json)
700 .map_err(|err| StoreError::Backend(format!("failed to decode {record_kind}: {err}")))?;
701 ensure_supported_record_schema_version(record_kind, &value, expected)?;
702 serde_json::from_value(value)
703 .map_err(|err| StoreError::Backend(format!("failed to decode {record_kind}: {err}")))
704}
705
706#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
707pub struct RuntimeTurnCommitStamp {
708 pub session_id: String,
709 pub turn_id: String,
710 pub turn_commit_hash: String,
711}
712
713impl RuntimeTurnCommitStamp {
714 pub fn new(
715 session_id: impl Into<String>,
716 turn_id: impl Into<String>,
717 turn_commit_hash: impl Into<String>,
718 ) -> Self {
719 Self {
720 session_id: session_id.into(),
721 turn_id: turn_id.into(),
722 turn_commit_hash: turn_commit_hash.into(),
723 }
724 }
725}
726
727fn build_persisted_turn_state(state: &crate::RuntimeSessionState) -> crate::PersistedTurnState {
728 crate::PersistedTurnState {
729 turn_index: state.turn_index,
730 token_usage: state.token_usage.clone(),
731 last_prompt_usage: state.last_prompt_usage.clone(),
732 protocol_turn_options: state.protocol_turn_options.clone(),
733 }
734}
735
736fn build_checkpoint_from_persisted_state(
737 state: &crate::RuntimeSessionState,
738) -> HydratedSessionCheckpoint {
739 HydratedSessionCheckpoint {
740 turn_state: build_persisted_turn_state(state),
741 tool_state_ref: state.tool_state_ref.clone(),
742 tool_state: state.tool_state_snapshot.clone(),
743 plugin_snapshot_ref: state.plugin_snapshot_ref.clone(),
744 plugin_snapshot_revision: state.plugin_snapshot_revision,
745 plugin_snapshot: state.plugin_snapshot.clone(),
746 execution_state_ref: state.execution_state_ref.clone(),
747 execution_state: state.execution_state_snapshot.clone(),
748 }
749}
750
751impl RuntimeCommit {
752 pub(crate) fn validate_claim_settlement(
753 &self,
754 originating_queue_claims: &[crate::QueuedWorkCompletion],
755 originating_turn_input_claims: &[crate::TurnInputCompletion],
756 ) -> Result<(), StoreError> {
757 for originating in originating_queue_claims {
758 if !self.completed_queue_claims.iter().any(|completed| {
759 completed.session_id == originating.session_id
760 && completed.claim_id == originating.claim_id
761 }) {
762 return Err(StoreError::UnsettledQueuedWorkClaim {
763 session_id: originating.session_id.clone(),
764 claim_id: originating.claim_id.clone(),
765 });
766 }
767 }
768 for originating in originating_turn_input_claims {
769 if !self.completed_turn_input_claims.iter().any(|completed| {
770 completed.session_id == originating.session_id
771 && completed.claim_id == originating.claim_id
772 }) {
773 return Err(StoreError::UnsettledTurnInputClaim {
774 session_id: originating.session_id.clone(),
775 claim_id: originating.claim_id.clone(),
776 });
777 }
778 }
779 Ok(())
780 }
781
782 pub fn turn_commit_hash(&self) -> Result<String, StoreError> {
783 let mut semantic_commit = self.clone();
784 semantic_commit.expected_head_revision = None;
785 semantic_commit.session_execution_lease = None;
786 semantic_commit.release_session_execution_lease = None;
787 semantic_commit.turn_commit = None;
788 let mut semantic_commit = serde_json::to_value(&semantic_commit).map_err(|err| {
789 StoreError::Backend(format!("failed to serialize runtime turn commit: {err}"))
790 })?;
791 scrub_turn_commit_hash_value(&mut semantic_commit);
792 crate::stable_hash::stable_json_sha256_hex(&semantic_commit).map_err(|err| {
793 StoreError::Backend(format!(
794 "failed to serialize runtime turn commit hash: {err}"
795 ))
796 })
797 }
798
799 pub fn persisted_state(
800 state: &crate::RuntimeSessionState,
801 usage_deltas: &[crate::TokenLedgerEntry],
802 ) -> Self {
803 Self {
804 session_id: state.session_id.clone(),
805 expected_head_revision: state.head_revision,
806 session_execution_lease: None,
807 release_session_execution_lease: None,
808 config: persisted_session_config_from_state(state),
809 agent_frames: state.agent_frames.clone(),
810 current_agent_frame_id: state.current_agent_frame_id.clone(),
811 graph: if state.graph_replace_required || state.head_revision.is_none() {
812 GraphCommitDelta::ReplaceFull(state.session_graph.clone())
813 } else {
814 GraphCommitDelta::Unchanged {
815 leaf_node_id: state.session_graph.leaf_node_id.clone(),
816 }
817 },
818 checkpoint: build_checkpoint_from_persisted_state(state),
819 usage_deltas: usage_deltas.to_vec(),
820 turn_commit: None,
821 completed_queue_claims: Vec::new(),
822 completed_turn_input_claims: Vec::new(),
823 enqueued_queue_batches: Vec::new(),
824 interrupted_turn_input_turn_id: None,
825 committed_attachment_ids: Vec::new(),
826 }
827 }
828
829 pub(crate) fn persisted_state_with_graph_commit(
830 state: &crate::RuntimeSessionState,
831 graph: GraphCommitDelta,
832 usage_deltas: &[crate::TokenLedgerEntry],
833 ) -> Self {
834 Self {
835 session_id: state.session_id.clone(),
836 expected_head_revision: state.head_revision,
837 session_execution_lease: None,
838 release_session_execution_lease: None,
839 config: persisted_session_config_from_state(state),
840 agent_frames: state.agent_frames.clone(),
841 current_agent_frame_id: state.current_agent_frame_id.clone(),
842 graph,
843 checkpoint: build_checkpoint_from_persisted_state(state),
844 usage_deltas: usage_deltas.to_vec(),
845 turn_commit: None,
846 completed_queue_claims: Vec::new(),
847 completed_turn_input_claims: Vec::new(),
848 enqueued_queue_batches: Vec::new(),
849 interrupted_turn_input_turn_id: None,
850 committed_attachment_ids: Vec::new(),
851 }
852 }
853
854 pub fn with_turn_commit(mut self, turn_commit: RuntimeTurnCommitStamp) -> Self {
855 self.turn_commit = Some(turn_commit);
856 self
857 }
858
859 pub fn with_session_execution_lease(mut self, lease: SessionExecutionLeaseFence) -> Self {
860 self.session_execution_lease = Some(lease);
861 self
862 }
863
864 pub fn releasing_session_execution_lease(
865 mut self,
866 completion: SessionExecutionLeaseCompletion,
867 ) -> Self {
868 self.release_session_execution_lease = Some(completion);
869 self
870 }
871
872 pub fn completing_queue_claim(
873 mut self,
874 completed_queue_claim: crate::QueuedWorkCompletion,
875 ) -> Self {
876 self.completed_queue_claims.push(completed_queue_claim);
877 self
878 }
879
880 pub fn completing_queue_claims(
881 mut self,
882 completed_queue_claims: impl IntoIterator<Item = crate::QueuedWorkCompletion>,
883 ) -> Self {
884 self.completed_queue_claims.extend(completed_queue_claims);
885 self
886 }
887
888 pub fn completing_turn_input_claim(
889 mut self,
890 completed_turn_input_claim: crate::TurnInputCompletion,
891 ) -> Self {
892 self.completed_turn_input_claims
893 .push(completed_turn_input_claim);
894 self
895 }
896
897 pub fn completing_turn_input_claims(
898 mut self,
899 completed_turn_input_claims: impl IntoIterator<Item = crate::TurnInputCompletion>,
900 ) -> Self {
901 self.completed_turn_input_claims
902 .extend(completed_turn_input_claims);
903 self
904 }
905
906 pub fn deferring_interrupted_turn_inputs(mut self, turn_id: impl Into<String>) -> Self {
907 self.interrupted_turn_input_turn_id = Some(turn_id.into());
908 self
909 }
910
911 pub fn with_committed_attachments(
912 mut self,
913 attachment_ids: impl IntoIterator<Item = crate::AttachmentId>,
914 ) -> Self {
915 self.committed_attachment_ids = attachment_ids.into_iter().collect();
916 self
917 }
918}
919
920fn scrub_turn_commit_hash_value(value: &mut serde_json::Value) {
921 match value {
922 serde_json::Value::Object(map) => {
923 let is_message = map.contains_key("role") && map.contains_key("parts");
924 let is_message_part = map.contains_key("kind")
925 && map.contains_key("content")
926 && map.contains_key("prune_state");
927 if is_message || is_message_part {
928 map.remove("id");
929 }
930 for volatile_key in ["node_id", "parent_node_id", "leaf_node_id", "timestamp"] {
931 map.remove(volatile_key);
932 }
933 for child in map.values_mut() {
934 scrub_turn_commit_hash_value(child);
935 }
936 }
937 serde_json::Value::Array(items) => {
938 for item in items {
939 scrub_turn_commit_hash_value(item);
940 }
941 }
942 _ => {}
943 }
944}
945
946fn persisted_session_state_from_head(
947 head: SessionHead,
948 checkpoint: Option<HydratedSessionCheckpoint>,
949) -> crate::RuntimeSessionState {
950 let mut state = crate::RuntimeSessionState {
951 session_id: head.session_id,
952 policy: crate::SessionPolicy::default(),
953 agent_frames: head.agent_frames,
954 current_agent_frame_id: head.current_agent_frame_id,
955 session_graph: head.graph,
956 turn_index: 0,
957 token_usage: crate::TokenUsage::default(),
958 last_prompt_usage: None,
959 protocol_turn_options: crate::ProtocolTurnOptions::default(),
960 tool_state_ref: None,
961 tool_state_generation: None,
962 tool_state_snapshot: None,
963 plugin_snapshot_ref: None,
964 plugin_snapshot_revision: None,
965 plugin_snapshot: None,
966 execution_state_ref: None,
967 execution_state_snapshot: None,
968 token_ledger: head.token_ledger,
969 checkpoint_ref: head.checkpoint_ref.clone(),
970 head_revision: Some(head.head_revision),
971 graph_replace_required: false,
972 };
973 state.policy.model = head.config.model.clone();
974 state.policy.provider_id = head.config.provider_id.clone();
975 if let Some(checkpoint) = checkpoint {
976 state.turn_index = checkpoint.turn_state.turn_index;
977 state.token_usage = checkpoint.turn_state.token_usage;
978 state.last_prompt_usage = checkpoint.turn_state.last_prompt_usage;
979 state.protocol_turn_options = checkpoint.turn_state.protocol_turn_options;
980 state.tool_state_ref = checkpoint.tool_state_ref.clone();
981 state.tool_state_generation = checkpoint
982 .tool_state
983 .as_ref()
984 .map(|snapshot| snapshot.generation());
985 state.tool_state_snapshot = checkpoint.tool_state;
986 state.plugin_snapshot_ref = checkpoint.plugin_snapshot_ref.clone();
987 state.plugin_snapshot_revision = checkpoint.plugin_snapshot_revision;
988 state.plugin_snapshot = checkpoint.plugin_snapshot;
989 state.execution_state_ref = checkpoint.execution_state_ref.clone();
990 state.execution_state_snapshot = checkpoint.execution_state;
991 }
992 state.ensure_agent_frame_initialized();
993 state
994}
995
996impl Default for SessionHead {
997 fn default() -> Self {
998 Self {
999 session_id: default_root_session_id(),
1000 head_revision: 0,
1001 agent_frames: Vec::new(),
1002 current_agent_frame_id: String::new(),
1003 graph: crate::SessionGraph::default(),
1004 config: crate::PersistedSessionConfig::default(),
1005 checkpoint_ref: None,
1006 token_ledger: Vec::new(),
1007 }
1008 }
1009}
1010
1011impl Default for SessionHeadMeta {
1012 fn default() -> Self {
1013 Self {
1014 schema_version: SESSION_HEAD_META_SCHEMA_VERSION,
1015 session_id: default_root_session_id(),
1016 head_revision: 0,
1017 config: crate::PersistedSessionConfig::default(),
1018 agent_frames: Vec::new(),
1019 current_agent_frame_id: String::new(),
1020 checkpoint_ref: None,
1021 leaf_node_id: None,
1022 graph_node_count: 0,
1023 token_ledger: Vec::new(),
1024 }
1025 }
1026}
1027
1028#[async_trait::async_trait]
1045pub trait SessionCommitStore: AttachmentManifest + Send + Sync {
1046 fn durability_tier(&self) -> crate::DurabilityTier {
1049 crate::DurabilityTier::Inline
1050 }
1051
1052 async fn load_session(
1053 &self,
1054 scope: SessionReadScope,
1055 ) -> Result<Option<PersistedSessionRead>, StoreError>;
1056
1057 async fn load_node(
1058 &self,
1059 node_id: &str,
1060 ) -> Result<Option<crate::SessionNodeRecord>, StoreError>;
1061
1062 async fn commit_runtime_state(
1063 &self,
1064 commit: RuntimeCommit,
1065 ) -> Result<RuntimeCommitResult, StoreError>;
1066
1067 async fn save_session_meta(&self, meta: SessionMeta) -> Result<(), StoreError>;
1068 async fn load_session_meta(&self) -> Result<Option<SessionMeta>, StoreError>;
1069}
1070
1071#[async_trait::async_trait]
1079pub trait TurnInputStore: Send + Sync {
1080 async fn enqueue_pending_turn_input(
1082 &self,
1083 input: crate::PendingTurnInputDraft,
1084 ) -> Result<crate::PendingTurnInput, StoreError>;
1085
1086 async fn list_pending_turn_inputs(
1091 &self,
1092 session_id: &str,
1093 ) -> Result<Vec<crate::PendingTurnInput>, StoreError>;
1094
1095 async fn cancel_pending_turn_input(
1102 &self,
1103 session_id: &str,
1104 input_id: &str,
1105 ) -> Result<crate::PendingTurnInputCancelOutcome, StoreError> {
1106 let target = crate::PendingTurnInputCancelTarget::input_id(input_id);
1107 let targets = vec![target];
1108 let mut outcomes = self
1109 .cancel_pending_turn_inputs(session_id, &targets)
1110 .await?;
1111 Ok(outcomes
1112 .pop()
1113 .map(|result| result.outcome)
1114 .unwrap_or(crate::PendingTurnInputCancelOutcome::NotFound))
1115 }
1116
1117 async fn cancel_pending_turn_inputs(
1119 &self,
1120 session_id: &str,
1121 targets: &[crate::PendingTurnInputCancelTarget],
1122 ) -> Result<Vec<crate::PendingTurnInputCancelResult>, StoreError>;
1123
1124 async fn cancel_pending_turn_input_suffix(
1126 &self,
1127 session_id: &str,
1128 anchor: &crate::PendingTurnInputCancelTarget,
1129 ) -> Result<crate::PendingTurnInputSuffixCancelOutcome, StoreError>;
1130
1131 async fn claim_active_turn_inputs(
1137 &self,
1138 session_id: &str,
1139 session_execution_lease: &SessionExecutionLeaseFence,
1140 owner: &LeaseOwnerIdentity,
1141 turn_id: &str,
1142 checkpoint: crate::CheckpointKind,
1143 max_inputs: usize,
1144 ) -> Result<Option<crate::TurnInputClaim>, StoreError>;
1145
1146 async fn claim_next_turn_inputs(
1148 &self,
1149 session_id: &str,
1150 session_execution_lease: &SessionExecutionLeaseFence,
1151 owner: &LeaseOwnerIdentity,
1152 max_inputs: usize,
1153 ) -> Result<Option<crate::TurnInputClaim>, StoreError>;
1154
1155 async fn abandon_turn_input_claim(
1157 &self,
1158 claim: &crate::TurnInputClaim,
1159 ) -> Result<(), StoreError>;
1160}
1161
1162#[async_trait::async_trait]
1165pub trait SessionExecutionLeaseStore: Send + Sync {
1166 async fn try_claim_session_execution_lease(
1173 &self,
1174 session_id: &str,
1175 owner: &LeaseOwnerIdentity,
1176 lease_ttl_ms: u64,
1177 ) -> Result<SessionExecutionLeaseClaimOutcome, StoreError>;
1178
1179 async fn reclaim_session_execution_lease(
1185 &self,
1186 session_id: &str,
1187 owner: &LeaseOwnerIdentity,
1188 observed_holder: &SessionExecutionLeaseFence,
1189 lease_ttl_ms: u64,
1190 ) -> Result<SessionExecutionLeaseClaimOutcome, StoreError>;
1191
1192 async fn renew_session_execution_lease(
1197 &self,
1198 fence: &SessionExecutionLeaseFence,
1199 lease_ttl_ms: u64,
1200 ) -> Result<SessionExecutionLease, StoreError>;
1201
1202 async fn release_session_execution_lease(
1206 &self,
1207 completion: &SessionExecutionLeaseCompletion,
1208 ) -> Result<(), StoreError>;
1209}
1210
1211#[async_trait::async_trait]
1217pub trait QueuedWorkStore: Send + Sync {
1218 async fn enqueue_queued_work(
1220 &self,
1221 batch: crate::QueuedWorkBatchDraft,
1222 ) -> Result<crate::QueuedWorkBatch, StoreError>;
1223
1224 async fn claim_leading_ready_session_command(
1231 &self,
1232 session_id: &str,
1233 session_execution_lease: &SessionExecutionLeaseFence,
1234 owner: &LeaseOwnerIdentity,
1235 ) -> Result<Option<crate::QueuedWorkClaim>, StoreError>;
1236
1237 async fn claim_ready_queued_work(
1244 &self,
1245 session_id: &str,
1246 session_execution_lease: &SessionExecutionLeaseFence,
1247 owner: &LeaseOwnerIdentity,
1248 boundary: crate::QueuedWorkClaimBoundary,
1249 max_batches: usize,
1250 ) -> Result<Option<crate::QueuedWorkClaim>, StoreError>;
1251
1252 async fn claim_ready_queued_work_by_batch_ids(
1263 &self,
1264 session_id: &str,
1265 session_execution_lease: &SessionExecutionLeaseFence,
1266 owner: &LeaseOwnerIdentity,
1267 boundary: crate::QueuedWorkClaimBoundary,
1268 batch_ids: &[String],
1269 ) -> Result<Option<crate::QueuedWorkClaim>, StoreError>;
1270
1271 async fn abandon_queued_work_claim(
1273 &self,
1274 claim: &crate::QueuedWorkClaim,
1275 ) -> Result<(), StoreError>;
1276
1277 async fn cancel_queued_work_batch(
1284 &self,
1285 session_id: &str,
1286 batch_id: &str,
1287 ) -> Result<Option<crate::QueuedWorkBatch>, StoreError>;
1288
1289 async fn list_queued_work(
1292 &self,
1293 session_id: &str,
1294 ) -> Result<Vec<crate::QueuedWorkBatch>, StoreError>;
1295
1296 async fn list_pending_queued_work(
1309 &self,
1310 session_id: &str,
1311 ) -> Result<Vec<crate::QueuedWorkBatch>, StoreError>;
1312}
1313
1314#[async_trait::async_trait]
1317pub trait StoreMaintenance: Send + Sync {
1318 async fn tombstone_nodes(&self, ids: &[String]) -> Result<(), StoreError>;
1321
1322 async fn vacuum(&self) -> Result<VacuumReport, StoreError>;
1325
1326 async fn gc_unreachable(&self) -> Result<GcReport, StoreError>;
1328}
1329
1330pub trait RuntimePersistence:
1347 SessionCommitStore
1348 + TurnInputStore
1349 + SessionExecutionLeaseStore
1350 + QueuedWorkStore
1351 + StoreMaintenance
1352{
1353}
1354
1355impl<T> RuntimePersistence for T where
1356 T: SessionCommitStore
1357 + TurnInputStore
1358 + SessionExecutionLeaseStore
1359 + QueuedWorkStore
1360 + StoreMaintenance
1361 + ?Sized
1362{
1363}
1364
1365fn persisted_session_state_from_read(read: PersistedSessionRead) -> crate::RuntimeSessionState {
1366 persisted_session_state_from_head(
1367 SessionHead {
1368 session_id: read.session_id,
1369 head_revision: read.head_revision,
1370 agent_frames: read.agent_frames,
1371 current_agent_frame_id: read.current_agent_frame_id,
1372 graph: read.graph,
1373 config: read.config,
1374 checkpoint_ref: read.checkpoint_ref,
1375 token_ledger: read.token_ledger,
1376 },
1377 read.checkpoint,
1378 )
1379}
1380
1381pub async fn load_persisted_session_state(
1382 store: &(dyn RuntimePersistence + '_),
1383) -> Result<Option<crate::RuntimeSessionState>, StoreError> {
1384 Ok(store
1385 .load_session(SessionReadScope::FullGraph)
1386 .await?
1387 .map(persisted_session_state_from_read))
1388}
1389
1390pub async fn load_persisted_session_state_active_path(
1391 store: &(dyn RuntimePersistence + '_),
1392 leaf_node_id: Option<String>,
1393) -> Result<Option<crate::RuntimeSessionState>, StoreError> {
1394 Ok(store
1395 .load_session(SessionReadScope::ActivePath { leaf_node_id })
1396 .await?
1397 .map(persisted_session_state_from_read))
1398}
1399
1400pub async fn refresh_persisted_session_state(
1401 store: &(dyn RuntimePersistence + '_),
1402 state: &mut crate::RuntimeSessionState,
1403) -> Result<(), StoreError> {
1404 if let Some(mut fresh) = load_persisted_session_state(store).await? {
1405 fresh.policy.session_id = state.policy.session_id.clone();
1406 fresh.policy.max_turns = state.policy.max_turns;
1407 *state = fresh;
1408 }
1409 Ok(())
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414 use super::{LeaseOwnerIdentity, LeaseOwnerLiveness};
1415
1416 fn local_liveness(
1417 host_id: &str,
1418 boot_id: &str,
1419 pid: u32,
1420 process_start: &str,
1421 ) -> LeaseOwnerLiveness {
1422 LeaseOwnerLiveness::local_process_for_test(host_id, boot_id, pid, process_start)
1423 }
1424
1425 #[test]
1426 fn lease_owner_identity_requires_same_incarnation() {
1427 let first = LeaseOwnerIdentity::opaque("owner", "incarnation-a");
1428 let same = LeaseOwnerIdentity::opaque("owner", "incarnation-a");
1429 let next = LeaseOwnerIdentity::opaque("owner", "incarnation-b");
1430
1431 assert!(first.same_incarnation(&same));
1432 assert!(!first.same_incarnation(&next));
1433 }
1434
1435 #[test]
1436 fn local_liveness_only_proves_same_host_boot_dead_processes() {
1437 let holder = local_liveness(
1438 "host-a",
1439 "boot-a",
1440 std::process::id(),
1441 "not-the-current-process-start",
1442 );
1443 let same_host_boot = local_liveness("host-a", "boot-a", std::process::id(), "claimant");
1444 let other_host = local_liveness("host-b", "boot-a", std::process::id(), "claimant");
1445 let other_boot = local_liveness("host-a", "boot-b", std::process::id(), "claimant");
1446
1447 assert!(holder.is_definitely_dead_for_claimant(&same_host_boot));
1448 assert!(!holder.is_definitely_dead_for_claimant(&other_host));
1449 assert!(!holder.is_definitely_dead_for_claimant(&other_boot));
1450 assert!(!holder.is_definitely_dead_for_claimant(&LeaseOwnerLiveness::Opaque));
1451 assert!(!LeaseOwnerLiveness::Opaque.is_definitely_dead_for_claimant(&same_host_boot));
1452 }
1453}