1use crate::llm::{Message, TokenUsage};
20use crate::verification::VerificationReport;
21use async_trait::async_trait;
22use serde::{Deserialize, Serialize};
23
24pub const LOOP_CHECKPOINT_SCHEMA_VERSION: u32 = 1;
27
28#[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 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct LoopCheckpoint {
90 #[serde(default)]
92 pub schema_version: u32,
93
94 pub run_id: String,
97
98 pub session_id: String,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub capability_binding: Option<crate::capability::RunCapabilityBindingV1>,
108
109 pub turn: usize,
112
113 pub messages: Vec<Message>,
116
117 pub total_usage: TokenUsage,
120
121 pub tool_calls_count: usize,
123
124 #[serde(default)]
126 pub verification_reports: Vec<VerificationReport>,
127
128 #[serde(default)]
131 pub convergence: LoopConvergenceState,
132
133 pub checkpoint_ms: u64,
137}
138
139impl LoopCheckpoint {
140 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 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 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#[async_trait]
214pub trait LoopCheckpointSink: Send + Sync {
215 async fn save_checkpoint(&self, checkpoint: &LoopCheckpoint);
220
221 async fn load_latest(&self, run_id: &str) -> Option<LoopCheckpoint>;
224}
225
226pub 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 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 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 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}