Skip to content

Commit 8e709c5

Browse files
ZhiXiao-Linclaude
andauthored
test(core): persisted-schema round-trip fuzz + reject future checkpoint versions (#31) (#49)
Adds core/tests/test_persisted_schema_roundtrip.rs — a dependency-free, seeded round-trip fuzz over the persisted graph (LoopCheckpoint, RunRecord incl. all three 3.3.0 AgentEvent variants, SubagentTaskSnapshot, TraceEvent, VerificationReport, Message/ContentBlock, and the largest root SessionData), asserting serialize→deserialize→re-serialize stability. Plus cross-version coverage: backward-compat (pre-3.3.0 payloads missing schema_version / identity fields / using the `todos` alias load with defaults), forward-compat (unknown fields ignored — guards rolling cluster upgrades against a stray deny_unknown_fields), the untagged ToolResultContentField and the persisted untagged PermissionRule dual-form hazards. Closes a real gap surfaced while writing it: LoopCheckpoint documented that loads from a future schema version must be rejected, but no impl enforced it. Adds LoopCheckpoint::ensure_loadable() and calls it in both the file and memory store load paths, so resume_run surfaces the error and the live-run sink starts fresh rather than misinterpreting an incompatible checkpoint. Co-authored-by: Claude <claude@anthropic.com>
1 parent 4470293 commit 8e709c5

4 files changed

Lines changed: 880 additions & 2 deletions

File tree

core/src/loop_checkpoint.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,36 @@ pub struct LoopCheckpoint {
6969
pub checkpoint_ms: u64,
7070
}
7171

72+
impl 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+
72102
/// Receiver of per-tool-round checkpoints.
73103
///
74104
/// The framework ships one adapter:

core/src/store/file_store.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,8 +466,12 @@ impl SessionStore for FileSessionStore {
466466
let json = fs::read_to_string(&path)
467467
.await
468468
.with_context(|| format!("Failed to read loop checkpoint from {}", path.display()))?;
469-
let checkpoint = serde_json::from_str(&json)
469+
let checkpoint: LoopCheckpoint = serde_json::from_str(&json)
470470
.with_context(|| format!("Failed to parse loop checkpoint from {}", path.display()))?;
471+
// Refuse to hand back a checkpoint written by a future, incompatible
472+
// schema version — its field semantics may differ (see the contract
473+
// on LoopCheckpoint::ensure_loadable).
474+
checkpoint.ensure_loadable()?;
471475
Ok(Some(checkpoint))
472476
}
473477

core/src/store/memory_store.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,15 @@ impl SessionStore for MemorySessionStore {
154154
}
155155

156156
async fn load_loop_checkpoint(&self, run_id: &str) -> Result<Option<LoopCheckpoint>> {
157-
Ok(self.loop_checkpoints.read().await.get(run_id).cloned())
157+
match self.loop_checkpoints.read().await.get(run_id).cloned() {
158+
// Enforce the same future-schema rejection as the file store so
159+
// the contract holds uniformly across backends.
160+
Some(cp) => {
161+
cp.ensure_loadable()?;
162+
Ok(Some(cp))
163+
}
164+
None => Ok(None),
165+
}
158166
}
159167

160168
async fn delete_loop_checkpoint(&self, run_id: &str) -> Result<()> {

0 commit comments

Comments
 (0)