Skip to content

Python: Isolate checkpoint state from live workflow state across restoration and storage boundaries - #7697

Open
Karan Dhaodiyal (karandhaodiyal28-hash) wants to merge 1 commit into
microsoft:mainfrom
karandhaodiyal28-hash:fix/agent-framework-7683-checkpoint-isolation
Open

Python: Isolate checkpoint state from live workflow state across restoration and storage boundaries#7697
Karan Dhaodiyal (karandhaodiyal28-hash) wants to merge 1 commit into
microsoft:mainfrom
karandhaodiyal28-hash:fix/agent-framework-7683-checkpoint-isolation

Conversation

@karandhaodiyal28-hash

Copy link
Copy Markdown

Fixes#7683.

Summary

WorkflowCheckpoint is documented as a stable, shareable snapshot of
workflow state — but the actual implementation leaked mutable state in
two places. A resumed workflow could silently corrupt a checkpoint
snapshot (or the storage backend's internal representation) through an
ordinary read / mutate / write-back pattern, with no exception raised.

Root cause

Two related but distinct isolation gaps, both surfaced in #7683:

  1. Restoration boundary.State.export_state() and
    State.import_state() were shallow copies (dict(self._committed)
    and self._committed.update(state)). Mutable values in a checkpoint
    remained aliased by reference between the snapshot and the live
    State it was built from or restored into.

  2. Storage boundary.InMemoryCheckpointStorage.save() already
    deep-copies before storing, but load(), list_checkpoints(), and
    get_latest() returned the internally stored object by reference —
    asymmetric with save().

This is inconsistent with the isolation that RunnerContext.build_checkpoint
already explicitly applies to messages (via a per-source list(messages)
copy), and with the docstring on WorkflowCheckpoint that says checkpoints
"can be shared and restored across different workflow instances of the same
workflow definition."

Fix

  • State.export_state() returns copy.deepcopy(self._committed).
  • State.import_state() deep-copies its argument before merging.
  • InMemoryCheckpointStorage.load(), list_checkpoints(), and
    get_latest() each return a deep copy, matching save().

The change is localized to the existing checkpoint / state boundaries
and uses only copy.deepcopy (already imported in _checkpoint.py,
added to _state.py).

Tests

Two existing repros from the issue are turned into regression tests,
plus additional coverage for edge cases.

tests/workflow/test_state.py (5 new tests, all pass; 23 pre-existing
tests continue to pass):

  • test_export_isolates_mutable_list_value — list values in an exported
    snapshot are independent of the source State.
  • test_export_isolates_mutable_dict_value — same for dict values.
  • test_import_does_not_alias_caller_state — caller-side dict is not
    aliased to committed state.
  • test_roundtrip_restoration_isolates_checkpoint — full export →
    import round-trip keeps a snapshot stable under in-place mutation
    (the original Repro 1).
  • test_export_returns_independent_dict — the exported dict itself
    cannot mutate the source.

tests/workflow/test_checkpoint_isolation_7683.py (new, 8 tests, all
pass):

  • test_load_returns_isolated_checkpoint — Repro 2: mutating a loaded
    checkpoint must not affect the backend.
  • test_load_returns_isolated_messages — the messages field is also
    nested mutable state and must be isolated.
  • test_list_checkpoints_returns_isolated_snapshots — mutating any
    checkpoint returned from list_checkpoints must not affect the
    backend.
  • test_list_checkpoints_filters_by_workflow_name — workflow_name
    filtering still works after isolation.
  • test_get_latest_returns_isolated_snapshot — mutating the result of
    get_latest must not affect any stored checkpoint.
  • test_get_latest_returns_none_when_empty — empty-storage contract.
  • test_load_missing_id_raises — error path.
  • test_save_returns_idsave() id-return contract.

Local verification: 36 passed, 0 failed in test_state.py +
test_checkpoint_isolation_7683.py (the full test_checkpoint.py suite
needs pytest-asyncio and other deps, which are not in this local env
but are present in CI).

Notes

The trade-off mentioned in the issue (extra copy cost on every
checkpoint build / restore / read) is acknowledged but should be
acceptable for the typical checkpoint cadence (superstep boundary, not
hot path), and matches the explicit isolation already applied to
messages. A targeted optimization — e.g. copy only the values crossing
the boundary, or a frozen-like wrapper — can be a follow-up if
profiling shows the cost is real.

…and storage boundaries
Closesmicrosoft#7683.
State.export_state() and State.import_state() used to perform shallow
copies, so any mutable container (list, dict) in a checkpoint snapshot
remained aliased by reference between the snapshot and the live State it
was built from or restored into. InMemoryCheckpointStorage.load(),
list_checkpoints(), and get_latest() similarly returned the internally
stored checkpoint object by reference, asymmetric with save() which
already deep-copies.
Both gaps let a resumed workflow silently corrupt a checkpoint snapshot
through an ordinary read/mutate/write-back pattern, with no exception
raised.
Fix:
- State.export_state() returns copy.deepcopy(self._committed) and
State.import_state() deep-copies its argument before merging, so a
snapshot is independent of any State it crosses.
- InMemoryCheckpointStorage.load(), list_checkpoints(), and
get_latest() now return deep copies of their stored checkpoints,
matching the existing save() isolation and closing the storage leak.
Tests:
- Added 5 isolation tests to tests/workflow/test_state.py covering
mutable list / dict values, caller-side aliasing, full round-trip
restoration, and dict-level independence.
- Added tests/workflow/test_checkpoint_isolation_7683.py with 8 tests
covering load(), list_checkpoints(), get_latest(), workflow_name
filtering, missing-id error path, messages field isolation, and the
save() id return contract.
All new tests pass; the 23 pre-existing state tests continue to pass.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@agent-framework-automationagent-framework-automationBot added the python Usage: [Issues, PRs], Target: Python label Aug 17, 2026
@github-actionsgithub-actionsBot changed the title Isolate checkpoint state from live workflow state across restoration and storage boundariesPython: Isolate checkpoint state from live workflow state across restoration and storage boundariesAug 17, 2026
@TaoChenOSU

Copy link
Copy Markdown
Contributor

/review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer adding the new tests to the existing test_checkpoint.py tests or a new test_immutable_checkpoint_test.py. Please do not include the issue number in the file name.

def test_load_returns_isolated_checkpoint() -> None:
"""Mutating a loaded checkpoint must not affect the storage backend."""

async def _run() -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make the tests themselves async.

@github-actions

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework/_workflows
_checkpoint.py158199%325
_state.py440100%
TOTAL46780435790%

Python Unit Test Overview

TestsSkippedFailuresErrorsTime
946536 💤0 ❌0 🔥2m 38s ⏱️

@github-actionsgithub-actionsBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 820acb5b06d6
Model:gpt-5.6-sol

Overview

The PR consistently applies deep-copy isolation at state export/import and at every object-returning in-memory storage boundary, with focused regression tests for nested mutable containers and existing storage contracts. Those guards address ordinary list and dictionary aliasing, but making deepcopy mandatory at the state boundary narrows the existing checkpoint value contract and can disable checkpoint creation or restoration for otherwise supported custom values.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
1 verified finding remained after source verification (1 medium) across 1 file. Details are attached to the affected lines below.

Affected areas:python/packages/core/agent_framework/_workflows/_state.py

safe to share with another workflow instance.
"""
return dict(self._committed)
return copy.deepcopy(self._committed)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checkpoint state supports custom pickle-serializable values through allowed_checkpoint_types, but those values are not necessarily deepcopyable. A value whose __deepcopy__ raises could previously be saved by file/Cosmos storage; this line now makes checkpoint creation fail before serialization, and the matching import copy makes existing checkpoints unrestorable. Please preserve isolation without narrowing the supported checkpoint-value contract, and cover a pickleable non-deepcopyable value in both build and restore paths.

@TaoChenOSU

Copy link
Copy Markdown
Contributor

Karan Dhaodiyal (@karandhaodiyal28-hash) Thank you for your contributions! Please address open comments and fix the failing code quality check.

@Shivani767Shivani . (Shivani767) left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Karan Dhaodiyal (@karandhaodiyal28-hash)Tao Chen (@TaoChenOSU) — following Tao's request on #7712 to fold the backend-parametrized ownership tests into this PR so the bug is addressed in one place.

What this PR already has that #7712 does not: the State.export_state / import_state isolation, which is the other half of #7683.

What #7712 uniquely adds (inline below):

  1. Protocol ownership contract. Please document ownership on CheckpointStorage itself, not only on the in-memory methods. File and Cosmos already satisfy it by reconstructing from serialized form; without a stated contract a new backend can silently diverge again. Suggested protocol docstring:
classCheckpointStorage(Protocol):
"""Protocol for checkpoint storage backends. Ownership: Checkpoints returned by ``load``, ``list_checkpoints`` and ``get_latest`` are owned by the caller. Mutating a returned checkpoint must not change stored state, and repeated reads must return independent objects. Symmetrically, ``save`` snapshots the checkpoint at call time, so mutating the caller's object afterwards must not change what was stored. Backends that serialize satisfy this implicitly, because decoding allocates a fresh object graph; backends that retain live objects must copy explicitly. """
  1. Replace test_checkpoint_isolation_7683.py with test_checkpoint_storage_conformance.py (no issue number; async tests — matching your earlier comments here). Parametrized over in-memory and file storage so the same ownership / snapshot contract cannot regress in one backend only. Also covers nested messages and pending_request_info_events.

I ran the replacement suite locally: 18 passed (9 tests × 2 backends). Same file is in #7712 if that's easier to copy.

A related nit: ruff format wants the list_checkpoints comprehension on one line. That wrap is likely the failing code quality check.

Comment on lines +235 to +237
return [
copy.deepcopy(cp) for cp in self._checkpoints.values() if cp.workflow_name == workflow_name
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff format (line-length 120) collapses this back to a single line, which is likely the failing code quality check.

Suggested change
return [
copy.deepcopy(cp) forcpinself._checkpoints.values() ifcp.workflow_name==workflow_name
]
return [copy.deepcopy(cp) forcpinself._checkpoints.values() ifcp.workflow_name==workflow_name]

@@ -0,0 +1,198 @@
# Copyright (c) Microsoft. All rights reserved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please replace this file with python/packages/core/tests/workflow/test_checkpoint_storage_conformance.py (no issue number, async tests). That also keeps these out of the already-large test_checkpoint.py.

The suite is parametrized over memory and file backends so the ownership contract is asserted for every in-tree storage, not only InMemoryCheckpointStorage. It covers nested state, metadata, messages, and pending_request_info_events, plus the filter / missing-id / empty-latest / save-returns-id cases from this PR.

Same file is in #7712

# Copyright (c) Microsoft. All rights reserved."""Conformance tests for the CheckpointStorage ownership contract.A checkpoint handed to the caller is owned by the caller, and a checkpointhanded to ``save()`` is snapshotted at call time. Backends that serialize(file, Cosmos) get this for free because decoding allocates a fresh objectgraph; backends that hold live objects must copy explicitly."""from __future__ importannotationsfrompathlibimportPathfromtypingimportAny, castimportpytestfromagent_frameworkimport (
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
WorkflowCheckpointException,
WorkflowEvent,
)
fromagent_framework._workflows._runner_contextimportWorkflowMessage@pytest.fixture(params=["memory", "file"])defconformance_storage(request: pytest.FixtureRequest, tmp_path: Path) ->CheckpointStorage:
"""Yield each in-tree checkpoint storage backend for the shared contract tests."""ifrequest.param=="memory":
returnInMemoryCheckpointStorage()
returnFileCheckpointStorage(tmp_path/"conformance")
def_conformance_checkpoint(workflow_name: str="conformance-workflow") ->WorkflowCheckpoint:
"""Build a checkpoint whose object graph holds nested mutable containers."""returnWorkflowCheckpoint(
workflow_name=workflow_name,
graph_signature_hash="conformance-hash",
state={
"shared": {"counter": 0, "history": ["initial"]},
"_executor_state": {"executor1": {"visits": ["first"]}},
},
messages={
"executor1": [
WorkflowMessage(
data={"text": "hello", "tags": ["initial"]},
source_id="src",
target_id="tgt",
)
]
},
pending_request_info_events={
"req1": WorkflowEvent.request_info(
request_id="req1",
source_executor_id="executor1",
request_data={"payload": ["initial"]},
response_type=str,
),
},
metadata={"tags": ["initial"]},
)
def_mutate(checkpoint: WorkflowCheckpoint) ->None:
"""Mutate every nested container the ownership contract covers."""checkpoint.state["shared"]["counter"] =999checkpoint.state["shared"]["history"].append("mutated")
checkpoint.state["_executor_state"]["executor1"]["visits"].append("mutated")
cast(dict[str, Any], checkpoint.messages["executor1"][0].data)["tags"].append("mutated")
cast(dict[str, Any], checkpoint.pending_request_info_events["req1"].data)["payload"].append("mutated")
checkpoint.metadata["tags"].append("mutated")
def_assert_pristine(checkpoint: WorkflowCheckpoint) ->None:
"""Assert nested containers still hold their original values."""assertcheckpoint.state["shared"]["counter"] ==0assertcheckpoint.state["shared"]["history"] == ["initial"]
assertcheckpoint.state["_executor_state"]["executor1"]["visits"] == ["first"]
assertcast(dict[str, Any], checkpoint.messages["executor1"][0].data)["tags"] == ["initial"]
assertcast(dict[str, Any], checkpoint.pending_request_info_events["req1"].data)["payload"] == ["initial"]
assertcheckpoint.metadata["tags"] == ["initial"]
asyncdeftest_conformance_load_returns_caller_owned_copy(conformance_storage: CheckpointStorage) ->None:
"""Mutating a loaded checkpoint must not alter what the backend has stored."""checkpoint=_conformance_checkpoint()
awaitconformance_storage.save(checkpoint)
loaded=awaitconformance_storage.load(checkpoint.checkpoint_id)
_mutate(loaded)
reloaded=awaitconformance_storage.load(checkpoint.checkpoint_id)
_assert_pristine(reloaded)
asyncdeftest_conformance_repeated_loads_are_independent(conformance_storage: CheckpointStorage) ->None:
"""Two loads of one checkpoint must not share mutable state."""checkpoint=_conformance_checkpoint()
awaitconformance_storage.save(checkpoint)
first=awaitconformance_storage.load(checkpoint.checkpoint_id)
second=awaitconformance_storage.load(checkpoint.checkpoint_id)
assertfirstisnotsecond_mutate(first)
_assert_pristine(second)
asyncdeftest_conformance_get_latest_returns_caller_owned_copy(conformance_storage: CheckpointStorage) ->None:
"""Mutating the result of get_latest must not alter stored state."""checkpoint=_conformance_checkpoint()
awaitconformance_storage.save(checkpoint)
latest=awaitconformance_storage.get_latest(workflow_name=checkpoint.workflow_name)
assertlatestisnotNone_mutate(latest)
reloaded=awaitconformance_storage.get_latest(workflow_name=checkpoint.workflow_name)
assertreloadedisnotNone_assert_pristine(reloaded)
asyncdeftest_conformance_list_checkpoints_returns_caller_owned_copies(
conformance_storage: CheckpointStorage,
) ->None:
"""Mutating a checkpoint from list_checkpoints must not alter stored state."""checkpoint=_conformance_checkpoint()
awaitconformance_storage.save(checkpoint)
listed=awaitconformance_storage.list_checkpoints(workflow_name=checkpoint.workflow_name)
assertlen(listed) ==1_mutate(listed[0])
relisted=awaitconformance_storage.list_checkpoints(workflow_name=checkpoint.workflow_name)
assertlen(relisted) ==1_assert_pristine(relisted[0])
asyncdeftest_conformance_save_snapshots_state_at_call_time(conformance_storage: CheckpointStorage) ->None:
"""Mutating the caller's object after save must not alter the stored checkpoint."""checkpoint=_conformance_checkpoint()
awaitconformance_storage.save(checkpoint)
_mutate(checkpoint)
loaded=awaitconformance_storage.load(checkpoint.checkpoint_id)
_assert_pristine(loaded)
asyncdeftest_conformance_list_checkpoints_filters_by_workflow_name(
conformance_storage: CheckpointStorage,
) ->None:
"""list_checkpoints must continue to filter by workflow_name after isolation."""first=_conformance_checkpoint(workflow_name="workflow-a")
second=_conformance_checkpoint(workflow_name="workflow-b")
awaitconformance_storage.save(first)
awaitconformance_storage.save(second)
listed_a=awaitconformance_storage.list_checkpoints(workflow_name="workflow-a")
listed_b=awaitconformance_storage.list_checkpoints(workflow_name="workflow-b")
assert {cp.checkpoint_idforcpinlisted_a} == {first.checkpoint_id}
assert {cp.checkpoint_idforcpinlisted_b} == {second.checkpoint_id}
asyncdeftest_conformance_get_latest_returns_none_when_empty(conformance_storage: CheckpointStorage) ->None:
"""get_latest must return None for an unknown workflow, not raise."""result=awaitconformance_storage.get_latest(workflow_name="missing-workflow")
assertresultisNoneasyncdeftest_conformance_load_missing_id_raises(conformance_storage: CheckpointStorage) ->None:
"""load must raise WorkflowCheckpointException for an unknown checkpoint id."""withpytest.raises(WorkflowCheckpointException):
awaitconformance_storage.load("does-not-exist")
asyncdeftest_conformance_save_returns_id(conformance_storage: CheckpointStorage) ->None:
"""save must return the checkpoint_id."""checkpoint=_conformance_checkpoint()
result=awaitconformance_storage.save(checkpoint)
assertresult==checkpoint.checkpoint_id

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

Labels

pythonUsage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: Checkpoint state is not isolated from live workflow state across restoration and storage boundaries

4 participants

@karandhaodiyal28-hash@TaoChenOSU@Shivani767