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//! [`crate::AgentSession::resume_run`] restores the checkpoint on a fresh run
17//! while preserving cumulative usage, turn budgets, and convergence guards.
18
19use crate::llm::{Message, TokenUsage};
20use crate::verification::VerificationReport;
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23
24/// Schema version. Bumped on incompatible format changes; impls of
25/// [`LoopCheckpointSink`] should reject loads from a future version.
26pub const LOOP_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
27
28/// Loop state that must survive crash recovery to preserve convergence limits.
29#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
30pub struct LoopConvergenceState {
31    #[serde(default)]
32    pub parse_error_count: u32,
33    #[serde(default)]
34    pub continuation_count: u32,
35    #[serde(default)]
36    pub reasoning_only_repair_count: u32,
37    /// Tool name, SHA-256 argument fingerprint, and outcome; never raw args.
38    #[serde(default)]
39    pub recent_tool_signatures: Vec<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub guarded_duplicate_signature: Option<String>,
42    #[serde(default)]
43    pub guarded_duplicate_count: u32,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub last_incomplete_response_hash: Option<String>,
46    #[serde(default)]
47    pub incomplete_response_stalled: bool,
48}
49
50/// Snapshot of the agent loop at the boundary between tool rounds.
51///
52/// Stored under `run_id` so resume tooling can address the correct run
53/// without scanning all checkpoints of a session.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct LoopCheckpoint {
56    /// Schema version — see [`LOOP_CHECKPOINT_SCHEMA_VERSION`].
57    #[serde(default)]
58    pub schema_version: u32,
59
60    /// Logical run identifier. Matches the `run_id` carried by
61    /// [`crate::run::RunSnapshot`] and `AgentEvent`s.
62    pub run_id: String,
63
64    /// Parent session id — redundant with `run_id` lookup but useful
65    /// for store layouts that key by `(session_id, run_id)`.
66    pub session_id: String,
67
68    /// Exact immutable capability catalog and authority ceiling admitted for
69    /// the source Run. Pre-binding checkpoints omit this field and retain the
70    /// legacy latest-catalog recovery behavior; newly emitted checkpoints bind
71    /// it and fail closed on generation drift.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub capability_binding: Option<crate::capability::RunCapabilityBindingV1>,
74
75    /// 1-based tool round counter at checkpoint time.
76    /// `0` is reserved for "no rounds completed yet".
77    pub turn: usize,
78
79    /// Conversation history including the just-returned tool results.
80    /// On resume, the new agent loop starts from this exact message list.
81    pub messages: Vec<Message>,
82
83    /// Running token usage at checkpoint time. Lets resume re-emit
84    /// progress metrics without re-querying the LLM provider.
85    pub total_usage: TokenUsage,
86
87    /// How many tool calls have been executed total in this run.
88    pub tool_calls_count: usize,
89
90    /// Verification reports collected so far in this run.
91    #[serde(default)]
92    pub verification_reports: Vec<VerificationReport>,
93
94    /// Counters and fingerprints that prevent convergence budgets from
95    /// resetting when a run resumes on another process.
96    #[serde(default)]
97    pub convergence: LoopConvergenceState,
98
99    /// Wall-clock timestamp when the checkpoint was written
100    /// (Unix epoch ms — sourced from the session's
101    /// [`HostEnv`](crate::host_env::HostEnv)).
102    pub checkpoint_ms: u64,
103}
104
105impl LoopCheckpoint {
106    /// Reject a checkpoint written by a *newer*, incompatible schema
107    /// version than this build understands — honoring the contract on
108    /// [`LOOP_CHECKPOINT_SCHEMA_VERSION`].
109    ///
110    /// Field *additions* are absorbed transparently by `#[serde(default)]`,
111    /// so an older checkpoint (lower `schema_version`, including a pre-v1
112    /// `0`) always remains loadable. A *future* version, however, may have
113    /// changed the meaning of existing fields or the tool-round boundary
114    /// semantics; resuming from one risks silent corruption (e.g.
115    /// re-running a non-idempotent tool on the wrong side of the boundary).
116    ///
117    /// [`SessionStore`](crate::store::SessionStore) impls call this right
118    /// after deserialization, so both `resume_run` (which surfaces the
119    /// error to the caller) and the live-run [`LoopCheckpointSink`] (which
120    /// logs and starts fresh) refuse to act on an unreadable checkpoint.
121    pub fn ensure_loadable(&self) -> anyhow::Result<()> {
122        if self.schema_version > LOOP_CHECKPOINT_SCHEMA_VERSION {
123            anyhow::bail!(
124                "loop checkpoint for run {} has schema version {} but this build supports at \
125                 most {}; refusing to resume from an incompatible future checkpoint",
126                self.run_id,
127                self.schema_version,
128                LOOP_CHECKPOINT_SCHEMA_VERSION
129            );
130        }
131        if let Some(binding) = &self.capability_binding {
132            binding.validate().map_err(|error| {
133                anyhow::anyhow!(
134                    "loop checkpoint for run {} has an invalid capability binding: {error}",
135                    self.run_id
136                )
137            })?;
138        }
139        Ok(())
140    }
141
142    /// Verify that this value is stored under the run key it claims to own.
143    ///
144    /// Store keys are caller-controlled. Checking the redundant identifier in
145    /// the payload prevents a record written under one key from being replayed
146    /// as a different run.
147    pub fn ensure_addressed_by(&self, run_id: &str) -> anyhow::Result<()> {
148        if self.run_id != run_id {
149            anyhow::bail!(
150                "loop checkpoint key mismatch: requested run {:?}, payload belongs to {:?}",
151                run_id,
152                self.run_id
153            );
154        }
155        Ok(())
156    }
157
158    /// Verify both the addressed run and its owning session before replay.
159    pub fn ensure_owned_by(&self, run_id: &str, session_id: &str) -> anyhow::Result<()> {
160        self.ensure_addressed_by(run_id)?;
161        if self.session_id != session_id {
162            anyhow::bail!(
163                "loop checkpoint ownership mismatch for run {:?}: current session is {:?}, payload belongs to {:?}",
164                run_id,
165                session_id,
166                self.session_id
167            );
168        }
169        Ok(())
170    }
171}
172
173/// Receiver of per-tool-round checkpoints.
174///
175/// The framework ships one adapter:
176/// [`SessionStoreCheckpointSink`] which forwards to a
177/// [`crate::store::SessionStore`]. Hosts can implement custom sinks
178/// (e.g. push directly to Redis) by implementing this trait.
179#[async_trait]
180pub trait LoopCheckpointSink: Send + Sync {
181    /// Persist a checkpoint. Called from inside the agent loop after a
182    /// successful tool round. Errors are logged at warn level and
183    /// otherwise swallowed — losing a checkpoint must not halt the
184    /// live run.
185    async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint);
186
187    /// Load the latest checkpoint for `run_id`, if any. Returns `None`
188    /// when no checkpoint has been recorded.
189    async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint>;
190}
191
192/// Default adapter that forwards checkpoints to a
193/// [`SessionStore`](crate::store::SessionStore). Construct via
194/// [`SessionStoreCheckpointSink::new`].
195pub struct SessionStoreCheckpointSink {
196    inner: std::sync::Arc<dyn crate::store::SessionStore>,
197}
198
199impl SessionStoreCheckpointSink {
200    pub fn new(store: std::sync::Arc<dyn crate::store::SessionStore>) -> Self {
201        Self { inner: store }
202    }
203}
204
205#[async_trait]
206impl LoopCheckpointSink for SessionStoreCheckpointSink {
207    async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint) {
208        if let Err(e) = self
209            .inner
210            .save_loop_checkpoint(&checkpoint.run_id, checkpoint)
211            .await
212        {
213            tracing::warn!(
214                run_id = %checkpoint.run_id,
215                error = %e,
216                "Loop checkpoint save failed; live run continues"
217            );
218        }
219    }
220
221    async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint> {
222        match self.inner.load_loop_checkpoint(run_id).await {
223            Ok(opt) => opt,
224            Err(e) => {
225                tracing::warn!(
226                    run_id = %run_id,
227                    error = %e,
228                    "Loop checkpoint load failed"
229                );
230                None
231            }
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn sample(run_id: &str, turn: usize) -> LoopCheckpoint {
241        LoopCheckpoint {
242            schema_version: LOOP_CHECKPOINT_SCHEMA_VERSION,
243            run_id: run_id.to_string(),
244            session_id: "session-1".to_string(),
245            capability_binding: None,
246            turn,
247            messages: vec![Message::user("hi")],
248            total_usage: TokenUsage::default(),
249            tool_calls_count: 0,
250            verification_reports: Vec::new(),
251            convergence: LoopConvergenceState::default(),
252            checkpoint_ms: 1_700_000_000_000,
253        }
254    }
255
256    #[test]
257    fn checkpoint_round_trips_through_json() {
258        let mut cp = sample("run-1", 3);
259        cp.convergence.continuation_count = 2;
260        cp.convergence.recent_tool_signatures = vec!["read:deadbeef => ok".to_string()];
261        let json = serde_json::to_string(&cp).unwrap();
262        let back: LoopCheckpoint = serde_json::from_str(&json).unwrap();
263        assert_eq!(back.run_id, "run-1");
264        assert_eq!(back.turn, 3);
265        assert_eq!(back.schema_version, LOOP_CHECKPOINT_SCHEMA_VERSION);
266        assert_eq!(back.convergence, cp.convergence);
267    }
268
269    #[test]
270    fn missing_schema_version_defaults_to_zero() {
271        // Older payloads without the field must still load — they'll
272        // be interpreted as a pre-v1 snapshot.
273        let json = r#"{
274            "run_id": "run-1",
275            "session_id": "s",
276            "turn": 1,
277            "messages": [],
278            "total_usage": {"prompt_tokens":0,"completion_tokens":0,"total_tokens":0},
279            "tool_calls_count": 0,
280            "checkpoint_ms": 0
281        }"#;
282        let cp: LoopCheckpoint = serde_json::from_str(json).unwrap();
283        assert_eq!(cp.schema_version, 0);
284        assert_eq!(cp.convergence, LoopConvergenceState::default());
285    }
286
287    #[test]
288    fn checkpoint_rejects_run_key_and_session_owner_mismatches() {
289        let cp = sample("run-1", 1);
290        assert!(cp.ensure_owned_by("run-1", "session-1").is_ok());
291
292        let run_error = cp.ensure_owned_by("run-2", "session-1").unwrap_err();
293        assert!(run_error.to_string().contains("key mismatch"));
294
295        let session_error = cp.ensure_owned_by("run-1", "session-2").unwrap_err();
296        assert!(session_error.to_string().contains("ownership mismatch"));
297    }
298}