Description
Description
State implements superstep-caching semantics: set() stages a write in a _pending buffer, commit() moves _pending into _committed at a superstep boundary, and discard() is documented as the way to abandon a superstep's staged writes without committing them.
However, State.discard() is never called by the workflow runner on failure or cancellation paths.
This matters because a Workflow intentionally keeps the same RunnerImpl / State instance alive across multiple run() calls. Therefore, pending writes left behind by a failed run can survive until the next successful run.
The sequence is:
- An executor calls
ctx.set_state(key, value), which stages the write in State._pending.
- The executor then raises an exception, causing the current superstep to fail.
RunnerImpl.run_until_convergence() catches the exception and re-raises it, but does not discard the pending state writes. The same applies to the cancellation path.
- The failed run therefore leaves the staged write in
State._pending.
- The same
Workflow instance can be reused for another run() call.
- During the later successful run, the runner reaches a superstep boundary and calls
State.commit().
commit() then commits the stale pending write from the previous failed run, even though the later run never wrote that state.
Actual behavior
A state value written during a failed superstep can silently become committed state during a later successful and otherwise unrelated Workflow.run().
There is no error or warning associated with the leaked state.
Expected behavior
State writes staged during a failed or cancelled superstep should be discarded and should never be committed by a later successful run.
Root cause
State.discard() already exists and clears the pending buffer, but it is not invoked by the runner's exception/cancellation paths.
This appears to be separate from #7683. #7683 concerns state isolation across checkpoint/restore/storage boundaries, while this issue concerns pending state writes surviving a failed superstep and being committed by a later run. The two issues have different triggers and mechanisms and can be fixed independently.
I'm happy to open a PR with a regression test and a minimal fix using the existing State.discard() mechanism, if this behavior is confirmed as unintended.
Code Sample
import asyncio
from dataclasses import dataclass
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
@dataclass
class Msg:
fail: bool
class FlakyExecutor(Executor):
@handler
async def run(self, message: Msg, ctx: WorkflowContext[Msg, str]) -> None:
if message.fail:
# This write is staged in State._pending.
ctx.set_state("secret", "leaked-from-failed-run")
# The superstep then fails before the pending state is committed.
raise RuntimeError("simulated transient failure")
# The successful run does not modify "secret".
await ctx.yield_output("ok")
async def main() -> None:
workflow = WorkflowBuilder(
start_executor=FlakyExecutor(id="flaky")
).build()
# Run 1: fails after staging a state write.
try:
async for _ in workflow.run(Msg(fail=True), stream=True):
pass
except RuntimeError:
pass
# Run 2: succeeds and does not write "secret".
async for _ in workflow.run(Msg(fail=False), stream=True):
pass
committed = workflow._runner.state.export_state()
print(committed)
# Expected:
# {'_workflow_run_kwargs': {}}
#
# Actual on the affected behavior:
# {'_workflow_run_kwargs': {}, 'secret': 'leaked-from-failed-run'}
if __name__ == "__main__":
asyncio.run(main())
Error Messages / Stack Traces
No exception is raised for the state leak itself.
Run 1 raises the expected:
RuntimeError("simulated transient failure")
Run 2 completes successfully, but the committed workflow state incorrectly
contains the "secret" value written during the failed Run 1.
This makes the issue a silent state-consistency/data-corruption bug rather
than a crash.
Package Versions
agent-framework-core: reproduced against main
Python Version
Python 3.13.15
Additional Context
Additional Context
Description
Description
Stateimplements superstep-caching semantics:set()stages a write in a_pendingbuffer,commit()moves_pendinginto_committedat a superstep boundary, anddiscard()is documented as the way to abandon a superstep's staged writes without committing them.However,
State.discard()is never called by the workflow runner on failure or cancellation paths.This matters because a
Workflowintentionally keeps the sameRunnerImpl/Stateinstance alive across multiplerun()calls. Therefore, pending writes left behind by a failed run can survive until the next successful run.The sequence is:
ctx.set_state(key, value), which stages the write inState._pending.RunnerImpl.run_until_convergence()catches the exception and re-raises it, but does not discard the pending state writes. The same applies to the cancellation path.State._pending.Workflowinstance can be reused for anotherrun()call.State.commit().commit()then commits the stale pending write from the previous failed run, even though the later run never wrote that state.Actual behavior
A state value written during a failed superstep can silently become committed state during a later successful and otherwise unrelated
Workflow.run().There is no error or warning associated with the leaked state.
Expected behavior
State writes staged during a failed or cancelled superstep should be discarded and should never be committed by a later successful run.
Root cause
State.discard()already exists and clears the pending buffer, but it is not invoked by the runner's exception/cancellation paths.This appears to be separate from #7683. #7683 concerns state isolation across checkpoint/restore/storage boundaries, while this issue concerns pending state writes surviving a failed superstep and being committed by a later run. The two issues have different triggers and mechanisms and can be fixed independently.
I'm happy to open a PR with a regression test and a minimal fix using the existing
State.discard()mechanism, if this behavior is confirmed as unintended.Code Sample
Error Messages / Stack Traces
No exception is raised for the state leak itself. Run 1 raises the expected: RuntimeError("simulated transient failure") Run 2 completes successfully, but the committed workflow state incorrectly contains the "secret" value written during the failed Run 1. This makes the issue a silent state-consistency/data-corruption bug rather than a crash.Package Versions
agent-framework-core: reproduced against main
Python Version
Python 3.13.15
Additional Context
Additional Context
mainbranch.Workflowinstance to be reused across multiplerun()calls, which is what allows the stale pending state to survive between runs.State.discard()already exists and is intended to clear pending state without committing it.