Skip to main content

awaken_runtime_contract/contract/
stream_checkpoint.rs

1//! Cross-process stream resume: persist mid-stream accumulated state so a
2//! new process (or a retry after a hard crash) can pick up where the
3//! previous attempt left off, instead of re-inferring the whole turn.
4//!
5//! ## Semantics
6//!
7//! A `StreamCheckpoint` is a *snapshot of accumulated deltas* scoped to a
8//! single `run_id`. Writers (the loop runner's `drive_one_stream`) flush
9//! the snapshot periodically as deltas arrive. Readers (the start of a
10//! new `execute_streaming` call) look up any stored checkpoint for the
11//! active `run_id` and, if present, mutate the request to include the
12//! previously-accumulated text as an assistant prefix + a continuation
13//! prompt — mechanically identical to the in-process R1 recovery path.
14//!
15//! ## Non-goals
16//!
17//! - The checkpoint is **not** a full conversation log. Committed
18//!   messages are still owned by `ThreadRunStore`. The checkpoint only
19//!   captures the in-flight delta accumulator.
20//! - The contract does **not** prescribe a retention policy. Production
21//!   implementations may bound the checkpoint TTL; the in-memory store
22//!   keeps everything until explicitly deleted.
23//! - The contract is provider-agnostic. A NATS JetStream-backed impl,
24//!   a filesystem impl, or a Redis impl all satisfy this trait.
25
26use std::collections::HashMap;
27use std::sync::Mutex;
28
29use async_trait::async_trait;
30use serde::{Deserialize, Serialize};
31use thiserror::Error;
32
33use super::executor::InFlightTool;
34use super::message::ToolCall;
35
36/// A persisted snapshot of in-flight stream accumulator state for a run.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct StreamCheckpoint {
39    /// Identifier of the run this checkpoint belongs to. Uniquely keys
40    /// the checkpoint in storage.
41    pub run_id: String,
42    /// Identifier of the containing thread. Carried for operator
43    /// inspection and for scoped bulk deletion (per-thread cleanup).
44    pub thread_id: String,
45    /// Upstream model the interrupted attempt targeted.
46    pub upstream_model: String,
47    /// Text content accumulated before interruption.
48    pub partial_text: String,
49    /// Tool calls whose arguments parsed cleanly before interruption.
50    /// On resume these are replayed as completed; the model does not
51    /// re-emit them.
52    pub completed_tool_calls: Vec<ToolCall>,
53    /// The open tool whose arguments had not finished arriving. Used to
54    /// surface a cancelled-tool hint on resume, same as R2.
55    pub in_flight_tool: Option<InFlightTool>,
56    /// Wall-clock millis when the writer last updated the checkpoint.
57    /// Used to bound staleness on read.
58    pub updated_at_ms: u64,
59}
60
61/// Backend-level failure (IO, network, serialization). The contract is a
62/// newtype rather than a multi-variant enum because every checkpoint
63/// backend collapses its native error to a string at this boundary —
64/// callers never branch on the cause.
65#[derive(Debug, Error)]
66#[error("stream checkpoint backend error: {0}")]
67pub struct StreamCheckpointError(pub String);
68
69/// Persistence for stream checkpoints.
70///
71/// The trait is deliberately small: implementations are free to batch,
72/// buffer, or shard under the hood. Callers assume sub-millisecond
73/// latency for the in-memory path and bounded (single-digit ms) latency
74/// for production backends.
75#[async_trait]
76pub trait StreamCheckpointStore: Send + Sync {
77    /// Upsert a checkpoint for `checkpoint.run_id`.
78    async fn put(&self, checkpoint: StreamCheckpoint) -> Result<(), StreamCheckpointError>;
79
80    /// Look up the most recent checkpoint for `run_id`, if any.
81    async fn get(&self, run_id: &str) -> Result<Option<StreamCheckpoint>, StreamCheckpointError>;
82
83    /// Remove the checkpoint for `run_id`. Idempotent: removing a
84    /// nonexistent key is not an error.
85    async fn delete(&self, run_id: &str) -> Result<(), StreamCheckpointError>;
86}
87
88/// Reference in-memory implementation. Suitable for tests and for
89/// single-process operation where cross-process resume is not needed
90/// but the in-process retry loop still benefits from the same
91/// checkpoint interface.
92pub struct InMemoryStreamCheckpointStore {
93    data: Mutex<HashMap<String, StreamCheckpoint>>,
94}
95
96impl Default for InMemoryStreamCheckpointStore {
97    fn default() -> Self {
98        Self {
99            data: Mutex::new(HashMap::new()),
100        }
101    }
102}
103
104impl InMemoryStreamCheckpointStore {
105    /// Create an empty store.
106    pub fn new() -> Self {
107        Self::default()
108    }
109
110    /// Count stored checkpoints (test-only helper).
111    pub fn len(&self) -> usize {
112        self.data.lock().unwrap().len()
113    }
114
115    /// True when no checkpoints are stored.
116    pub fn is_empty(&self) -> bool {
117        self.len() == 0
118    }
119}
120
121#[async_trait]
122impl StreamCheckpointStore for InMemoryStreamCheckpointStore {
123    async fn put(&self, checkpoint: StreamCheckpoint) -> Result<(), StreamCheckpointError> {
124        let mut guard = self.data.lock().unwrap();
125        guard.insert(checkpoint.run_id.clone(), checkpoint);
126        Ok(())
127    }
128
129    async fn get(&self, run_id: &str) -> Result<Option<StreamCheckpoint>, StreamCheckpointError> {
130        Ok(self.data.lock().unwrap().get(run_id).cloned())
131    }
132
133    async fn delete(&self, run_id: &str) -> Result<(), StreamCheckpointError> {
134        self.data.lock().unwrap().remove(run_id);
135        Ok(())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn sample(run_id: &str) -> StreamCheckpoint {
144        StreamCheckpoint {
145            run_id: run_id.into(),
146            thread_id: "thread-1".into(),
147            upstream_model: "test-model".into(),
148            partial_text: "hello".into(),
149            completed_tool_calls: vec![],
150            in_flight_tool: None,
151            updated_at_ms: 1_000,
152        }
153    }
154
155    #[tokio::test]
156    async fn in_memory_put_get_delete_roundtrip() {
157        let store = InMemoryStreamCheckpointStore::new();
158        assert!(store.is_empty());
159
160        store.put(sample("run-a")).await.unwrap();
161        store.put(sample("run-b")).await.unwrap();
162        assert_eq!(store.len(), 2);
163
164        let got = store.get("run-a").await.unwrap().unwrap();
165        assert_eq!(got.run_id, "run-a");
166        assert_eq!(got.partial_text, "hello");
167
168        store.delete("run-a").await.unwrap();
169        assert_eq!(store.len(), 1);
170        assert!(store.get("run-a").await.unwrap().is_none());
171    }
172
173    #[tokio::test]
174    async fn delete_nonexistent_is_not_an_error() {
175        let store = InMemoryStreamCheckpointStore::new();
176        store.delete("no-such-run").await.unwrap();
177    }
178
179    #[tokio::test]
180    async fn put_overwrites_existing_entry() {
181        let store = InMemoryStreamCheckpointStore::new();
182        store.put(sample("run-a")).await.unwrap();
183
184        let updated = StreamCheckpoint {
185            partial_text: "updated text".into(),
186            updated_at_ms: 2_000,
187            ..sample("run-a")
188        };
189        store.put(updated).await.unwrap();
190
191        let got = store.get("run-a").await.unwrap().unwrap();
192        assert_eq!(got.partial_text, "updated text");
193        assert_eq!(got.updated_at_ms, 2_000);
194    }
195
196    #[test]
197    fn checkpoint_serde_roundtrip() {
198        let checkpoint = sample("run-1");
199        let json = serde_json::to_string(&checkpoint).unwrap();
200        let parsed: StreamCheckpoint = serde_json::from_str(&json).unwrap();
201        assert_eq!(parsed.run_id, "run-1");
202        assert_eq!(parsed.partial_text, "hello");
203    }
204}