Skip to main content

a3s_code_core/
loop_checkpoint.rs

1//! Per-tool-round loop checkpoints for crash-tolerant runs (P3 cut 1).
2//!
3//! The agent loop persists a [`LoopCheckpoint`] after each completed tool
4//! round. The checkpoint captures the minimum state needed to recreate
5//! the loop's position so a future process — typically on a different
6//! node, dispatched by the host after a crash or planned migration — can
7//! resume from the last consistent boundary.
8//!
9//! Boundary policy: checkpoints are taken **only** between tool rounds,
10//! never mid-tool. If a process dies while a tool is executing, the
11//! work of that round is lost on resume; the LLM re-deliberates from
12//! the previous checkpoint. This trades retry cost for correctness —
13//! re-executing a non-idempotent tool (write, bash) on the wrong side
14//! of the boundary is worse than re-asking the LLM.
15//!
16//! Resume API (cut 2 follow-up): not part of this cut. This module
17//! lands the data contract + persistence wiring; an
18//! `AgentSession::resume_run(run_id)` entry point will live on top.
19
20use crate::llm::{Message, TokenUsage};
21use crate::verification::VerificationReport;
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24
25/// Schema version. Bumped on incompatible format changes; impls of
26/// [`LoopCheckpointSink`] should reject loads from a future version.
27pub const LOOP_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
28
29/// Snapshot of the agent loop at the boundary between tool rounds.
30///
31/// Stored under `run_id` so resume tooling can address the correct run
32/// without scanning all checkpoints of a session.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct LoopCheckpoint {
35    /// Schema version — see [`LOOP_CHECKPOINT_SCHEMA_VERSION`].
36    #[serde(default)]
37    pub schema_version: u32,
38
39    /// Logical run identifier. Matches the `run_id` carried by
40    /// [`crate::run::RunSnapshot`] and `AgentEvent`s.
41    pub run_id: String,
42
43    /// Parent session id — redundant with `run_id` lookup but useful
44    /// for store layouts that key by `(session_id, run_id)`.
45    pub session_id: String,
46
47    /// 1-based tool round counter at checkpoint time.
48    /// `0` is reserved for "no rounds completed yet".
49    pub turn: usize,
50
51    /// Conversation history including the just-returned tool results.
52    /// On resume, the new agent loop starts from this exact message list.
53    pub messages: Vec<Message>,
54
55    /// Running token usage at checkpoint time. Lets resume re-emit
56    /// progress metrics without re-querying the LLM provider.
57    pub total_usage: TokenUsage,
58
59    /// How many tool calls have been executed total in this run.
60    pub tool_calls_count: usize,
61
62    /// Verification reports collected so far in this run.
63    #[serde(default)]
64    pub verification_reports: Vec<VerificationReport>,
65
66    /// Wall-clock timestamp when the checkpoint was written
67    /// (Unix epoch ms — sourced from the session's
68    /// [`HostEnv`](crate::host_env::HostEnv)).
69    pub checkpoint_ms: u64,
70}
71
72impl LoopCheckpoint {
73    /// Reject a checkpoint written by a *newer*, incompatible schema
74    /// version than this build understands — honoring the contract on
75    /// [`LOOP_CHECKPOINT_SCHEMA_VERSION`].
76    ///
77    /// Field *additions* are absorbed transparently by `#[serde(default)]`,
78    /// so an older checkpoint (lower `schema_version`, including a pre-v1
79    /// `0`) always remains loadable. A *future* version, however, may have
80    /// changed the meaning of existing fields or the tool-round boundary
81    /// semantics; resuming from one risks silent corruption (e.g.
82    /// re-running a non-idempotent tool on the wrong side of the boundary).
83    ///
84    /// [`SessionStore`](crate::store::SessionStore) impls call this right
85    /// after deserialization, so both `resume_run` (which surfaces the
86    /// error to the caller) and the live-run [`LoopCheckpointSink`] (which
87    /// logs and starts fresh) refuse to act on an unreadable checkpoint.
88    pub fn ensure_loadable(&self) -> anyhow::Result<()> {
89        if self.schema_version > LOOP_CHECKPOINT_SCHEMA_VERSION {
90            anyhow::bail!(
91                "loop checkpoint for run {} has schema version {} but this build supports at \
92                 most {}; refusing to resume from an incompatible future checkpoint",
93                self.run_id,
94                self.schema_version,
95                LOOP_CHECKPOINT_SCHEMA_VERSION
96            );
97        }
98        Ok(())
99    }
100
101    /// Verify that this value is stored under the run key it claims to own.
102    ///
103    /// Store keys are caller-controlled. Checking the redundant identifier in
104    /// the payload prevents a record written under one key from being replayed
105    /// as a different run.
106    pub fn ensure_addressed_by(&self, run_id: &str) -> anyhow::Result<()> {
107        if self.run_id != run_id {
108            anyhow::bail!(
109                "loop checkpoint key mismatch: requested run {:?}, payload belongs to {:?}",
110                run_id,
111                self.run_id
112            );
113        }
114        Ok(())
115    }
116
117    /// Verify both the addressed run and its owning session before replay.
118    pub fn ensure_owned_by(&self, run_id: &str, session_id: &str) -> anyhow::Result<()> {
119        self.ensure_addressed_by(run_id)?;
120        if self.session_id != session_id {
121            anyhow::bail!(
122                "loop checkpoint ownership mismatch for run {:?}: current session is {:?}, payload belongs to {:?}",
123                run_id,
124                session_id,
125                self.session_id
126            );
127        }
128        Ok(())
129    }
130}
131
132/// Receiver of per-tool-round checkpoints.
133///
134/// The framework ships one adapter:
135/// [`SessionStoreCheckpointSink`] which forwards to a
136/// [`crate::store::SessionStore`]. Hosts can implement custom sinks
137/// (e.g. push directly to Redis) by implementing this trait.
138#[async_trait]
139pub trait LoopCheckpointSink: Send + Sync {
140    /// Persist a checkpoint. Called from inside the agent loop after a
141    /// successful tool round. Errors are logged at warn level and
142    /// otherwise swallowed — losing a checkpoint must not halt the
143    /// live run.
144    async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint);
145
146    /// Load the latest checkpoint for `run_id`, if any. Returns `None`
147    /// when no checkpoint has been recorded.
148    async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint>;
149}
150
151/// Default adapter that forwards checkpoints to a
152/// [`SessionStore`](crate::store::SessionStore). Construct via
153/// [`SessionStoreCheckpointSink::new`].
154pub struct SessionStoreCheckpointSink {
155    inner: std::sync::Arc<dyn crate::store::SessionStore>,
156}
157
158impl SessionStoreCheckpointSink {
159    pub fn new(store: std::sync::Arc<dyn crate::store::SessionStore>) -> Self {
160        Self { inner: store }
161    }
162}
163
164#[async_trait]
165impl LoopCheckpointSink for SessionStoreCheckpointSink {
166    async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint) {
167        if let Err(e) = self
168            .inner
169            .save_loop_checkpoint(&checkpoint.run_id, checkpoint)
170            .await
171        {
172            tracing::warn!(
173                run_id = %checkpoint.run_id,
174                error = %e,
175                "Loop checkpoint save failed; live run continues"
176            );
177        }
178    }
179
180    async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint> {
181        match self.inner.load_loop_checkpoint(run_id).await {
182            Ok(opt) => opt,
183            Err(e) => {
184                tracing::warn!(
185                    run_id = %run_id,
186                    error = %e,
187                    "Loop checkpoint load failed"
188                );
189                None
190            }
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn sample(run_id: &str, turn: usize) -> LoopCheckpoint {
200        LoopCheckpoint {
201            schema_version: LOOP_CHECKPOINT_SCHEMA_VERSION,
202            run_id: run_id.to_string(),
203            session_id: "session-1".to_string(),
204            turn,
205            messages: vec![Message::user("hi")],
206            total_usage: TokenUsage::default(),
207            tool_calls_count: 0,
208            verification_reports: Vec::new(),
209            checkpoint_ms: 1_700_000_000_000,
210        }
211    }
212
213    #[test]
214    fn checkpoint_round_trips_through_json() {
215        let cp = sample("run-1", 3);
216        let json = serde_json::to_string(&cp).unwrap();
217        let back: LoopCheckpoint = serde_json::from_str(&json).unwrap();
218        assert_eq!(back.run_id, "run-1");
219        assert_eq!(back.turn, 3);
220        assert_eq!(back.schema_version, LOOP_CHECKPOINT_SCHEMA_VERSION);
221    }
222
223    #[test]
224    fn missing_schema_version_defaults_to_zero() {
225        // Older payloads without the field must still load — they'll
226        // be interpreted as a pre-v1 snapshot.
227        let json = r#"{
228            "run_id": "run-1",
229            "session_id": "s",
230            "turn": 1,
231            "messages": [],
232            "total_usage": {"prompt_tokens":0,"completion_tokens":0,"total_tokens":0},
233            "tool_calls_count": 0,
234            "checkpoint_ms": 0
235        }"#;
236        let cp: LoopCheckpoint = serde_json::from_str(json).unwrap();
237        assert_eq!(cp.schema_version, 0);
238    }
239
240    #[test]
241    fn checkpoint_rejects_run_key_and_session_owner_mismatches() {
242        let cp = sample("run-1", 1);
243        assert!(cp.ensure_owned_by("run-1", "session-1").is_ok());
244
245        let run_error = cp.ensure_owned_by("run-2", "session-1").unwrap_err();
246        assert!(run_error.to_string().contains("key mismatch"));
247
248        let session_error = cp.ensure_owned_by("run-1", "session-2").unwrap_err();
249        assert!(session_error.to_string().contains("ownership mismatch"));
250    }
251}