description
_save_executor_states in _runner.py calls executor.on_checkpoint_save() and stores whatever comes back with no shape check. _restore_executor_states then validates isinstance(state, dict) and raises WorkflowCheckpointException("Executor state for {id} is not a dict[str, Any]. Unable to restore.").
so an executor whose on_checkpoint_save returns a list (or any non-dict) checkpoints successfully, the run completes, and every later restore of those checkpoints fails. this is the same save-accepts-what-restore-rejects principle as #8181, but a different locus (the executor state hooks, _runner.py, not the file storage) and it reproduces on InMemoryCheckpointStorage too, so it is not pickle- or storage-specific.
the contract is documented on the Executor base class (on_checkpoint_save() -> dict[str, Any], _executor.py:525), so a returning a non-dict is user error, but the failure surfaces only at restore time, potentially in a different process. a save-side isinstance check (raise WorkflowCheckpointException from _save_executor_states) would make it loud at capture time.
one related observation i am not claiming as a bug: when checkpoint encoding fails mid-run, the run continues by design ("note that this does not fail the workflow run" in the log message). combined with the asymmetry above, an executor returning an unencodable value means the run finishes with no checkpoints at all and only log lines saying so. that may be intended resilience, flagging it only as context.
checked tests (test_checkpoint.py, test_agent_executor.py) and the tracker: nothing pins the non-dict path and no duplicate found. found via a probe of the executor state hooks. ai assistance disclosed. standalone offline repro below.
reproduction steps
import asyncio, sys
sys.path.insert(0, "python/packages/core")
from agent_framework import WorkflowBuilder, WorkflowContext, handler
from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage
from agent_framework._workflows._executor import Executor
class BadStateExecutor(Executor):
@handler
async def run(self, message: str, ctx: WorkflowContext) -> None:
await ctx.yield_output(message + "-done")
async def on_checkpoint_save(self):
return ["not", "a", "dict"] # type: ignore[override]
async def main():
store = InMemoryCheckpointStorage()
ex = BadStateExecutor(id="only")
wf = WorkflowBuilder(max_iterations=5, start_executor=ex, checkpoint_storage=store).build()
_ = [e async for e in wf.run("hi", stream=True)] # run completes
ckpts = await store.list_checkpoints(workflow_name=wf.name)
print(f"{len(ckpts)} checkpoints saved") # checkpoints exist
ex2 = BadStateExecutor(id="only")
wf2 = WorkflowBuilder(max_iterations=5, start_executor=ex2, checkpoint_storage=store).build()
_ = [e async for e in wf2.run(checkpoint_id=ckpts[-1].checkpoint_id, stream=True)]
# WorkflowCheckpointException: Executor state for only is not a dict[str, Any]. Unable to restore.
asyncio.run(main())
environment
- agent-framework-core @ main $SHA (editable, python/packages/core)
- python 3.12, macOS, offline repro
description
_save_executor_statesin_runner.pycallsexecutor.on_checkpoint_save()and stores whatever comes back with no shape check._restore_executor_statesthen validatesisinstance(state, dict)and raisesWorkflowCheckpointException("Executor state for {id} is not a dict[str, Any]. Unable to restore.").so an executor whose
on_checkpoint_savereturns a list (or any non-dict) checkpoints successfully, the run completes, and every later restore of those checkpoints fails. this is the same save-accepts-what-restore-rejects principle as #8181, but a different locus (the executor state hooks,_runner.py, not the file storage) and it reproduces onInMemoryCheckpointStoragetoo, so it is not pickle- or storage-specific.the contract is documented on the
Executorbase class (on_checkpoint_save() -> dict[str, Any],_executor.py:525), so a returning a non-dict is user error, but the failure surfaces only at restore time, potentially in a different process. a save-sideisinstancecheck (raiseWorkflowCheckpointExceptionfrom_save_executor_states) would make it loud at capture time.one related observation i am not claiming as a bug: when checkpoint encoding fails mid-run, the run continues by design ("note that this does not fail the workflow run" in the log message). combined with the asymmetry above, an executor returning an unencodable value means the run finishes with no checkpoints at all and only log lines saying so. that may be intended resilience, flagging it only as context.
checked tests (
test_checkpoint.py,test_agent_executor.py) and the tracker: nothing pins the non-dict path and no duplicate found. found via a probe of the executor state hooks. ai assistance disclosed. standalone offline repro below.reproduction steps
environment