Repository navigation
Python: fix get_latest silently returning a stale checkpoint on decode failure - #9902
pratik wayase (PratikWayase) wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Duplicate-key misclassification and skipped validation of malformed files can silently return stale checkpoints, while a regression test can fail with RecursionError on supported Python versions.
5 open issues
Identify root duplicates from the final object_pairs_hook callback · New Track file versions before parsing checkpoint contents · New Avoid recursive Path.resolve calls in the stat patch · New Retry scans when queued directories disappear · New Filter foreign workflows before validating envelope markers · New
What changed in this PR
This PR addresses #7831 by changing Python’s FileCheckpointStorage.get_latest() to select checkpoints from stored metadata and surface decode failures rather than silently return older state.
Changes:
- Selects and decodes only the newest matching checkpoint, sharing decoding logic with
load(). - Adds snapshot validation and bounded retries for concurrent filesystem changes.
- Adds regression coverage for decode failures, ownership validation, concurrent changes, and unchanged tolerant listing behavior.
| File | Description |
|---|---|
python/packages/core/tests/workflow/test_checkpoint.py |
Adds checkpoint-selection, corruption, and concurrency regression tests. |
python/packages/core/agent_framework/_workflows/_checkpoint.py |
Implements metadata-based selection, snapshot validation, retries, and shared decoding. |
Review effort: Balanced
…etry, foreign-workflow ordering
|
pratik wayase (@PratikWayase) Checkpoint discovery checks only the entry name before treating it as a checkpoint candidate. A valid child store located at a directory such as |


Motivation & Context
This is a follow-up to #9485, which got closed under the stale-contribution rule while I was still working on Eduard van Valkenburg (@eavanvalkenburg)'s review feedback. Both of his comments are now addressed, with regression tests for each.
The bug itself:
FileCheckpointStorage.get_latest()was silently returning stale state. When the newest checkpoint on disk couldn't be decoded (say, itsstatepayload referenced a type not inallowed_checkpoint_types),get_latestwould just fall back to the next-newest checkpoint it could read and return that as if it were the latest. A workflow would then resume from earlier state, believing its newer state was saved.The root cause is that
get_latestwas built on top oflist_checkpoints, which catches per-file decode failures, logs a warning, and returns whatever it could read. The only signal of the refusal was alogger.warningon a code path nobody watches during recovery. Issue #7831 describes the bug and the three behaviors that combine to produce it.Description & Review Guide
What are the major changes?
get_latestno longer goes throughlist_checkpoints. It scans the storage directory itself in three phases:Metadata-only scan. For each
.jsonfile under the storage root (recursively, with symlink/cycle protection), it reads only the plain-JSON envelope —workflow_name,timestamp,checkpoint_id. No payload decoding happens here. Files whoseworkflow_namedoesn't match the requested workflow get skipped; any file that could belong to the requested workflow but is unreadable raisesWorkflowCheckpointException.Post-scan validation. Re-stat every file that was read (using
Path.stat()consistently) and re-scan the directory tree. If anything changed during the scan — a file's mtime/size/ctime, the set of files, the set of directories, or the storage root itself — raise_CheckpointScanInvalidated. The outer retry loop catches it and retries up to 3 times with backoff.Decode only the winner. Decode the selected checkpoint from the dict that was already read in phase 1 — do not reopen the file via
load(), because that could decode a replacement rather than the validated snapshot. If decode fails, raise; do not fall back to an older checkpoint.list_checkpoints()is intentionally left unchanged. It stays tolerant — one bad file shouldn't break listing. The asymmetry is pinned by a new test (test_file_checkpoint_storage_list_checkpoints_still_skips_undecodable_entries). Issue #8181 raises the broader question of whetherlist_checkpointsshould also surface decode errors; I'm not taking a position on that here, just leaving the door open for a follow-up.I also extracted a small
_decode_checkpoint()helper shared betweenload()andget_latest()so both apply the sameallowed_checkpoint_typespolicy. Behavior ofload()is unchanged.What is the impact of these changes?
get_lateststops decoding every checkpoint just to return one — only the winner gets decoded.get_latestcall are detected and retried internally, instead of silently returning a snapshot that may already be stale, or failing recovery after a single scan.list_checkpoints()behavior is unchanged — nothing that lists checkpoints starts failing.workflow_name, or duplicateworkflow_nameat the root) are quarantined (skipped with a warning) so one malformed or legacy file can't permanently break recovery for every workflow sharing the storage root. Corruption in a file that is identified as belonging to the requested workflow still raises.What do you want reviewers to focus on?
The two review comments from Eduard van Valkenburg (@eavanvalkenburg) on Python: fix get_latest silently returning a stale checkpoint on decode failure #9485 are addressed:
FileNotFoundErrorduring the initial read (between_checkpoint_paths()andstat()/open()) is now caught separately and re-raised as_CheckpointScanInvalidated, so the outer retry loop handles it. The post-loop re-stat catchesFileNotFoundErrorthe same way. Deleting even an unrelated workflow's checkpoint mid-scan no longer fails otherwise healthy recovery after one scan — the next attempt returns the surviving checkpoints. Pinned bytest_file_checkpoint_storage_get_latest_retries_when_checkpoint_disappears_before_read.workflow_name._make_duplicate_capturing_hooknow returns separateroot_duplicate_keysandall_duplicate_keystrackers. The ambiguous-ownership check uses onlyroot_duplicate_keys, so a duplicatedworkflow_nameinsidemetadataorstateno longer quarantines a matching-workflow file as unowned. The nested duplicate is surfaced as a key-uniqueness violation once ownership is established. Pinned bytest_file_checkpoint_storage_get_latest_surfaces_nested_duplicate_workflow_name. This closes the silent-stale-state recurrence that the prior review flagged.The Windows-specific deviation from the original PR Python: raise instead of silently returning a stale checkpoint from get_latest #7832 design. On Windows,
Path.stat()andos.fstat(fd)use different Win32 APIs (GetFileAttributesExWvsGetFileInformationByHandle) and can return different values forst_dev,st_ino, and evenst_mtime_ns/st_ctime_nson the same stable file. To make the scan work on Windows, I reduced the version tuple to(st_size, st_mtime_ns, st_ctime_ns)and removed the two intra-fileos.fstat()comparisons (pre-read and post-read). The post-loop re-stat usesPath.stat()consistently and still detects concurrent writes. Symlink escapes are caught separately by_validate_file_pathand theis_relative_to(root)check in_checkpoint_paths, so droppingst_devdoesn't weaken that guard. Reviewers on Linux/macOS should sanity-check that the reduced tuple is still sufficient there.The decision to keep
list_checkpointstolerant. This is intentional and pinned by a test, but if maintainers want symmetry withget_latest(per Python: FileCheckpointStorage saves state it cannot restore: silent save/load asymmetry, get_latest invisibility, listing disagreement, and undocumented JSONDecodeError #8181), that's a follow-up PR — not this one.The metadata-only scan and
checkpoint_idvalidation.checkpoint_idmust be a string and must resolve to the exact file being read (_validate_file_path(checkpoint_id) != file_path.resolve()→ raise). This catches redirected-ID attacks where a file's metadata claims to be a different checkpoint. Top-level__pickled__/__type__markers are rejected because they could hide different metadata.Related Issue
Fixes #7831
AI Assistance
AI assistance details: AI assistance was used only to help understand the issue and the surrounding code. All implementation, tests, and this PR description were written by me.
Contribution Checklist
get_latestpreviously returned a stale checkpoint on decode failure of the newest; it now raises. Callers that depended on the silent-fallback behavior will see a new exception. Nolist_checkpoints/load/save/deletebehavior changes.