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    #[serde(default, skip_serializing_if = "is_zero")]
49    pub gate_continuation_count: u32,
50    #[serde(default, skip_serializing_if = "is_default_mutation_ledger")]
51    pub mutations: crate::harness_loop::MutationLedger,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub open_observations: Vec<crate::external_observation::ExternalObservationV1>,
54    #[serde(default, skip_serializing_if = "is_false")]
55    pub verifier_spent: bool,
56    #[serde(default, skip_serializing_if = "is_false")]
57    pub next_turn_is_verifier: bool,
58    /// Git baseline from run start. A resumed process deltas against this
59    /// instead of a fresh snapshot, so an unobserved write still opens the gate.
60    #[serde(default, skip_serializing_if = "is_false")]
61    pub workspace_watch_bound: bool,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub workspace_porcelain: Option<Vec<String>>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub workspace_head: Option<String>,
66    /// Length and mtime of porcelain paths at run start. A later write that
67    /// keeps the same status line is still a mutation on resume.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub(crate) workspace_stamps: Vec<crate::porcelain::ContentStamp>,
70}
71
72fn is_zero(value: &u32) -> bool {
73    *value == 0
74}
75
76fn is_false(value: &bool) -> bool {
77    !*value
78}
79
80fn is_default_mutation_ledger(ledger: &crate::harness_loop::MutationLedger) -> bool {
81    ledger == &crate::harness_loop::MutationLedger::default()
82}
83
84/// Snapshot of the agent loop at the boundary between tool rounds.
85///
86/// Stored under `run_id` so resume tooling can address the correct run
87/// without scanning all checkpoints of a session.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LoopCheckpoint {
90    /// Schema version — see [`LOOP_CHECKPOINT_SCHEMA_VERSION`].
91    #[serde(default)]
92    pub schema_version: u32,
93
94    /// Logical run identifier. Matches the `run_id` carried by
95    /// [`crate::run::RunSnapshot`] and `AgentEvent`s.
96    pub run_id: String,
97
98    /// Parent session id — redundant with `run_id` lookup but useful
99    /// for store layouts that key by `(session_id, run_id)`.
100    pub session_id: String,
101
102    /// Exact immutable capability catalog and authority ceiling admitted for
103    /// the source Run. Pre-binding checkpoints omit this field and retain the
104    /// legacy latest-catalog recovery behavior; newly emitted checkpoints bind
105    /// it and fail closed on generation drift.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub capability_binding: Option<crate::capability::RunCapabilityBindingV1>,
108
109    /// 1-based tool round counter at checkpoint time.
110    /// `0` is reserved for "no rounds completed yet".
111    pub turn: usize,
112
113    /// Conversation history including the just-returned tool results.
114    /// On resume, the new agent loop starts from this exact message list.
115    pub messages: Vec<Message>,
116
117    /// Running token usage at checkpoint time. Lets resume re-emit
118    /// progress metrics without re-querying the LLM provider.
119    pub total_usage: TokenUsage,
120
121    /// How many tool calls have been executed total in this run.
122    pub tool_calls_count: usize,
123
124    /// Verification reports collected so far in this run.
125    #[serde(default)]
126    pub verification_reports: Vec<VerificationReport>,
127
128    /// Counters and fingerprints that prevent convergence budgets from
129    /// resetting when a run resumes on another process.
130    #[serde(default)]
131    pub convergence: LoopConvergenceState,
132
133    /// Wall-clock timestamp when the checkpoint was written
134    /// (Unix epoch ms — sourced from the session's
135    /// [`HostEnv`](crate::host_env::HostEnv)).
136    pub checkpoint_ms: u64,
137}
138
139impl LoopCheckpoint {
140    /// Reject a checkpoint written by a *newer*, incompatible schema
141    /// version than this build understands — honoring the contract on
142    /// [`LOOP_CHECKPOINT_SCHEMA_VERSION`].
143    ///
144    /// Field *additions* are absorbed transparently by `#[serde(default)]`,
145    /// so an older checkpoint (lower `schema_version`, including a pre-v1
146    /// `0`) always remains loadable. A *future* version, however, may have
147    /// changed the meaning of existing fields or the tool-round boundary
148    /// semantics; resuming from one risks silent corruption (e.g.
149    /// re-running a non-idempotent tool on the wrong side of the boundary).
150    ///
151    /// [`SessionStore`](crate::store::SessionStore) impls call this right
152    /// after deserialization, so both `resume_run` (which surfaces the
153    /// error to the caller) and the live-run [`LoopCheckpointSink`] (which
154    /// logs and starts fresh) refuse to act on an unreadable checkpoint.
155    pub fn ensure_loadable(&self) -> anyhow::Result<()> {
156        if self.schema_version > LOOP_CHECKPOINT_SCHEMA_VERSION {
157            anyhow::bail!(
158                "loop checkpoint for run {} has schema version {} but this build supports at \
159                 most {}; refusing to resume from an incompatible future checkpoint",
160                self.run_id,
161                self.schema_version,
162                LOOP_CHECKPOINT_SCHEMA_VERSION
163            );
164        }
165        if let Some(binding) = &self.capability_binding {
166            binding.validate().map_err(|error| {
167                anyhow::anyhow!(
168                    "loop checkpoint for run {} has an invalid capability binding: {error}",
169                    self.run_id
170                )
171            })?;
172        }
173        Ok(())
174    }
175
176    /// Verify that this value is stored under the run key it claims to own.
177    ///
178    /// Store keys are caller-controlled. Checking the redundant identifier in
179    /// the payload prevents a record written under one key from being replayed
180    /// as a different run.
181    pub fn ensure_addressed_by(&self, run_id: &str) -> anyhow::Result<()> {
182        if self.run_id != run_id {
183            anyhow::bail!(
184                "loop checkpoint key mismatch: requested run {:?}, payload belongs to {:?}",
185                run_id,
186                self.run_id
187            );
188        }
189        Ok(())
190    }
191
192    /// Verify both the addressed run and its owning session before replay.
193    pub fn ensure_owned_by(&self, run_id: &str, session_id: &str) -> anyhow::Result<()> {
194        self.ensure_addressed_by(run_id)?;
195        if self.session_id != session_id {
196            anyhow::bail!(
197                "loop checkpoint ownership mismatch for run {:?}: current session is {:?}, payload belongs to {:?}",
198                run_id,
199                session_id,
200                self.session_id
201            );
202        }
203        Ok(())
204    }
205}
206
207/// Receiver of per-tool-round checkpoints.
208///
209/// The framework ships one adapter:
210/// [`SessionStoreCheckpointSink`] which forwards to a
211/// [`crate::store::SessionStore`]. Hosts can implement custom sinks
212/// (e.g. push directly to Redis) by implementing this trait.
213#[async_trait]
214pub trait LoopCheckpointSink: Send + Sync {
215    /// Persist a checkpoint. Called from inside the agent loop after a
216    /// successful tool round. Errors are logged at warn level and
217    /// otherwise swallowed — losing a checkpoint must not halt the
218    /// live run.
219    async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint);
220
221    /// Load the latest checkpoint for `run_id`, if any. Returns `None`
222    /// when no checkpoint has been recorded.
223    async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint>;
224}
225
226/// Default adapter that forwards checkpoints to a
227/// [`SessionStore`](crate::store::SessionStore). Construct via
228/// [`SessionStoreCheckpointSink::new`].
229pub struct SessionStoreCheckpointSink {
230    inner: std::sync::Arc<dyn crate::store::SessionStore>,
231}
232
233impl SessionStoreCheckpointSink {
234    pub fn new(store: std::sync::Arc<dyn crate::store::SessionStore>) -> Self {
235        Self { inner: store }
236    }
237}
238
239#[async_trait]
240impl LoopCheckpointSink for SessionStoreCheckpointSink {
241    async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint) {
242        if let Err(e) = self
243            .inner
244            .save_loop_checkpoint(&checkpoint.run_id, checkpoint)
245            .await
246        {
247            tracing::warn!(
248                run_id = %checkpoint.run_id,
249                error = %e,
250                "Loop checkpoint save failed; live run continues"
251            );
252        }
253    }
254
255    async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint> {
256        match self.inner.load_loop_checkpoint(run_id).await {
257            Ok(opt) => opt,
258            Err(e) => {
259                tracing::warn!(
260                    run_id = %run_id,
261                    error = %e,
262                    "Loop checkpoint load failed"
263                );
264                None
265            }
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::store::SessionStore;
274    use async_trait::async_trait;
275
276    fn sample(run_id: &str, turn: usize) -> LoopCheckpoint {
277        LoopCheckpoint {
278            schema_version: LOOP_CHECKPOINT_SCHEMA_VERSION,
279            run_id: run_id.to_string(),
280            session_id: "session-1".to_string(),
281            capability_binding: None,
282            turn,
283            messages: vec![Message::user("hi")],
284            total_usage: TokenUsage::default(),
285            tool_calls_count: 0,
286            verification_reports: Vec::new(),
287            convergence: LoopConvergenceState::default(),
288            checkpoint_ms: 1_700_000_000_000,
289        }
290    }
291
292    #[test]
293    fn checkpoint_round_trips_through_json() {
294        let mut cp = sample("run-1", 3);
295        cp.convergence.continuation_count = 2;
296        cp.convergence.recent_tool_signatures = vec!["read:deadbeef => ok".to_string()];
297        cp.convergence.gate_continuation_count = 1;
298        cp.convergence.verifier_spent = true;
299        cp.convergence.workspace_watch_bound = true;
300        let json = serde_json::to_string(&cp).unwrap();
301        assert!(json.contains("\"gate_continuation_count\":1"));
302        assert!(!json.contains("\"open_observations\""));
303        let back: LoopCheckpoint = serde_json::from_str(&json).unwrap();
304        assert_eq!(back.run_id, "run-1");
305        assert_eq!(back.turn, 3);
306        assert_eq!(back.schema_version, LOOP_CHECKPOINT_SCHEMA_VERSION);
307        assert_eq!(back.convergence, cp.convergence);
308    }
309
310    #[test]
311    fn missing_schema_version_defaults_to_zero() {
312        // Older payloads without the field must still load — they'll
313        // be interpreted as a pre-v1 snapshot.
314        let json = r#"{
315            "run_id": "run-1",
316            "session_id": "s",
317            "turn": 1,
318            "messages": [],
319            "total_usage": {"prompt_tokens":0,"completion_tokens":0,"total_tokens":0},
320            "tool_calls_count": 0,
321            "checkpoint_ms": 0
322        }"#;
323        let cp: LoopCheckpoint = serde_json::from_str(json).unwrap();
324        assert_eq!(cp.schema_version, 0);
325        assert_eq!(cp.convergence, LoopConvergenceState::default());
326    }
327
328    #[test]
329    fn checkpoint_rejects_run_key_and_session_owner_mismatches() {
330        let cp = sample("run-1", 1);
331        assert!(cp.ensure_owned_by("run-1", "session-1").is_ok());
332
333        let run_error = cp.ensure_owned_by("run-2", "session-1").unwrap_err();
334        assert!(run_error.to_string().contains("key mismatch"));
335
336        let session_error = cp.ensure_owned_by("run-1", "session-2").unwrap_err();
337        assert!(session_error.to_string().contains("ownership mismatch"));
338    }
339
340    #[test]
341    fn ensure_loadable_rejects_future_schema_and_accepts_current() {
342        let mut cp = sample("run-future", 1);
343        assert!(cp.ensure_loadable().is_ok());
344        assert!(cp.ensure_addressed_by("run-future").is_ok());
345        assert!(cp
346            .ensure_addressed_by("other")
347            .unwrap_err()
348            .to_string()
349            .contains("key mismatch"));
350
351        cp.schema_version = LOOP_CHECKPOINT_SCHEMA_VERSION + 1;
352        let err = cp.ensure_loadable().unwrap_err().to_string();
353        assert!(err.contains("schema version"));
354        assert!(err.contains("refusing to resume"));
355    }
356
357    #[tokio::test]
358    async fn session_store_checkpoint_sink_round_trips_and_survives_store_errors() {
359        use crate::store::MemorySessionStore;
360        use std::sync::Arc;
361
362        let cp = sample("run-sink", 2);
363        let ok_store = Arc::new(MemorySessionStore::new());
364        let sink = SessionStoreCheckpointSink::new(ok_store);
365        sink.save_checkpoint(&cp).await;
366        let loaded = sink.load_latest("run-sink").await;
367        assert_eq!(
368            loaded.as_ref().map(|value| value.run_id.as_str()),
369            Some("run-sink")
370        );
371        assert_eq!(loaded.unwrap().turn, 2);
372        assert!(sink.load_latest("missing-run").await.is_none());
373    }
374
375    #[test]
376    fn ensure_loadable_rejects_invalid_capability_binding() {
377        let mut cp = sample("run-cap", 1);
378        let digest = format!("sha256:{}", "0".repeat(64));
379        let binding: crate::capability::RunCapabilityBindingV1 =
380            serde_json::from_value(serde_json::json!({
381                "schema": "bad.schema",
382                "capabilitySetSchema": "a3s.code.capability-set.v1",
383                "codeCatalogGeneration": 1,
384                "catalogDigest": digest,
385                "capabilityCeilingSchema": "a3s.code.capability-ceiling.v1",
386                "capabilityCeilingDigest": digest
387            }))
388            .expect("structurally complete binding with unsupported schema");
389        cp.capability_binding = Some(binding);
390        let err = cp.ensure_loadable().unwrap_err().to_string();
391        assert!(
392            err.contains("invalid capability binding") || err.contains("capability"),
393            "{err}"
394        );
395    }
396
397    /// Minimal store that only fails checkpoint I/O — every override line is hit.
398    struct FailingCheckpointStore;
399
400    #[async_trait]
401    impl crate::store::SessionStore for FailingCheckpointStore {
402        async fn save(&self, _: &crate::store::SessionData) -> anyhow::Result<()> {
403            Ok(())
404        }
405        async fn load(&self, _: &str) -> anyhow::Result<Option<crate::store::SessionData>> {
406            Ok(None)
407        }
408        async fn delete(&self, _: &str) -> anyhow::Result<()> {
409            Ok(())
410        }
411        async fn list(&self) -> anyhow::Result<Vec<String>> {
412            Ok(Vec::new())
413        }
414        async fn exists(&self, _: &str) -> anyhow::Result<bool> {
415            Ok(false)
416        }
417        async fn save_loop_checkpoint(&self, _: &str, _: &LoopCheckpoint) -> anyhow::Result<()> {
418            Err(anyhow::anyhow!("checkpoint save failed"))
419        }
420        async fn load_loop_checkpoint(&self, _: &str) -> anyhow::Result<Option<LoopCheckpoint>> {
421            Err(anyhow::anyhow!("checkpoint load failed"))
422        }
423        fn backend_name(&self) -> &str {
424            "failing-checkpoint"
425        }
426    }
427
428    #[tokio::test]
429    async fn session_store_checkpoint_sink_swallows_store_errors() {
430        let sink = SessionStoreCheckpointSink::new(std::sync::Arc::new(FailingCheckpointStore));
431        sink.save_checkpoint(&sample("run-fail", 1)).await;
432        assert!(sink.load_latest("run-fail").await.is_none());
433    }
434
435    #[tokio::test]
436    async fn failing_checkpoint_store_covers_unused_session_store_surface() {
437        let store = FailingCheckpointStore;
438        // Hit every non-checkpoint SessionStore stub without building SessionData.
439        assert!(store.load("any").await.unwrap().is_none());
440        store.delete("any").await.unwrap();
441        assert!(store.list().await.unwrap().is_empty());
442        assert!(!store.exists("any").await.unwrap());
443        assert_eq!(store.backend_name(), "failing-checkpoint");
444    }
445}