Skip to content

Python: [Bug]: get_latest returns a stale checkpoint when the newest one cannot be decoded #7831

Description

Description

A checkpoint that save accepts can be impossible to read back, and when that happens get_latest returns an older checkpoint instead of reporting the problem. A workflow then resumes from earlier state, having been told its newer state was saved.

Three behaviors combine to produce that, none of them wrong on its own. Encoding applies no allow list, so save accepts any state value, while decoding does apply one, so load can refuse the same checkpoint. Listing catches every exception per file, logs a warning and returns whatever it could read. And get_latest is built on list_checkpoints, so it inherits the partial view and picks the newest of the survivors.

The only signal is a logger.warning, on a code path nobody is watching during recovery.

Closed issue #3529 raised this API from the other side. Its list_checkpoints row reads TODO: improve this API .get_latest(), error, human in the list, and the issue closed once get_latest() existed. get_latest() was built on list_checkpoints, so the error half of that line is still open.

The recent work on the allow list is what makes the refusal path worth handling rather than hypothetical. #7791 registers built-in orchestration types and #7789 reports group chat envelope types being rejected on restore. Both widen what decode accepts, which is the right fix for those types. Neither changes what recovery does when decode refuses something anyway, and any application type outside the registered set still lands on that path.

The Azure Cosmos storage in agent_framework_azure_cosmos._checkpoint_storage uses the same catch-and-warn pattern, and its get_latest also builds on its own listing, so it appears to share the behavior. I have not verified that, because I cannot exercise it without an Azure account, and my pull request does not touch it.

I have a fix ready with tests and will open a pull request alongside this issue.

Code Sample

import asyncio
import tempfile
from dataclasses import dataclass

from agent_framework._workflows._checkpoint import FileCheckpointStorage, WorkflowCheckpoint


@dataclass
class AppState:
    stage: str


def checkpoint(state: dict, timestamp: str) -> WorkflowCheckpoint:
    return WorkflowCheckpoint(
        workflow_name="recovery-probe",
        graph_signature_hash="probe-signature",
        state=state,
        timestamp=timestamp,
    )


async def main() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        storage = FileCheckpointStorage(storage_path=tmp)

        await storage.save(checkpoint({"stage": "step-1"}, "2026-08-16T10:00:00+00:00"))
        newer_id = await storage.save(checkpoint({"app": AppState(stage="step-2")}, "2026-08-16T11:00:00+00:00"))
        print(f"save of the newer checkpoint succeeded, id={newer_id}")

        latest = await storage.get_latest(workflow_name="recovery-probe")
        print(f"get_latest returns {latest.state if latest else None}")
        print(f"is it the newest save? {latest is not None and latest.checkpoint_id == newer_id}")


asyncio.run(main())

Output on main at d9d3fb62:

save of the newer checkpoint succeeded, id=48a6f608-b93e-4a71-acef-8692833a9e4f
get_latest returns {'stage': 'step-1'}
is it the newest save? False

Expected: get_latest either returns the newest checkpoint or raises. Returning an older one is the answer to a question nobody asked.

Error Messages / Stack Traces

The warning that precedes the wrong answer is the only trace the refusal leaves:

Failed to read checkpoint file /tmp/.../48a6f608-b93e-4a71-acef-8692833a9e4f.json:
Failed to decode pickled checkpoint data: Checkpoint deserialization blocked for
type '__main__:AppState'.

Package Versions

agent-framework-core: 1.13.0

Python Version

Python 3.13.7

Additional Context

Restricting deserialization by default is a deliberate security control, and per storage configuration through allowed_checkpoint_types is how an application widens it. Nothing about that needs to change. The problem is only that a refusal to decode is currently converted into a plausible wrong answer instead of an error.

workflow_name, timestamp and checkpoint_id are stored as plain JSON, so the newest checkpoint can be identified without decoding any payload. My pull request takes that route, which also means get_latest stops decoding every checkpoint in order to return one, and it leaves list_checkpoints tolerant exactly as it is today, so nothing that lists checkpoints starts failing.

Activity

  1. added
    pythonUsage: [Issues, PRs], Target: Python
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 23, 2026
  2. added
    reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflow
    on Aug 23, 2026
  3. github-actions commented on Aug 23, 2026

    @github-actions
    Contributor

    🤖 Automated triage reproduction notes (agent-authored — trust but verify)

    Agent analysis

    Repro: python/packages/core/agent_framework/_workflows/_checkpoint.py::FileCheckpointStorage.get_latest lines 423-437 returns stale state because list_checkpoints lines 373-401 suppresses decoding errors. Trigger it by saving an older primitive checkpoint followed by a newer checkpoint containing an unregistered dataclass, then call get_latest() without configuring allowed_checkpoint_types; the focused test demonstrates the call returns normally instead of raising for the unreadable newest checkpoint.

    • Failing test: python/packages/core/tests/workflow/test_checkpoint.py::test_file_checkpoint_storage_get_latest_raises_when_newest_checkpoint_cannot_be_decoded
    • Files examined: python/packages/core/agent_framework/_workflows/_checkpoint.py, python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py, python/packages/core/tests/workflow/test_checkpoint.py, python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py, python/packages/core/pyproject.toml
    • Tests run: test_file_checkpoint_storage_get_latest, test_file_checkpoint_storage_get_latest_raises_when_newest_checkpoint_cannot_be_decoded
    • Reported version: 1.13.0
    • Current version: 1.15.0
  4. added
    workflowsUsage: [Issues, PRs], Target: Workflows
    and removed
    triageUsage: [Issues], Target: All issues that still need to be triaged
    on Aug 23, 2026
  5. feiiiiii5 commented on Aug 24, 2026

    @feiiiiii5
    Contributor

    Related consistency note while #7832 is in review: FileCheckpointStorage.list_checkpoints (same file) still swallows per-file decode failures with a warning and silently omits them from the result. #7832 establishes the principle that a checkpoint that cannot be decoded must not be silently skipped ("silently resuming from earlier state is worse than failing"), but list_checkpoints — which is now public API with no in-repo caller after the get_latest refactor — keeps the old silent-skip behavior. Should the same rule apply there (surface the decode failure) for consistency, or is silent-omit intentional for listing? Happy to align the fix with whatever direction #7832 settles on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

pythonUsage: [Issues, PRs], Target: Pythonready-for-implementationMaintainers agree the problem and direction are ready for implementation.reproducedUsage: [Issues], Target: all issues that can be reproduced by the triage workflowworkflowsUsage: [Issues, PRs], Target: Workflows

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions