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}