Skip to content

Python: [Bug]: Pending State writes from a failed superstep leak into a later successful run #7859

Description

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:

  1. An executor calls ctx.set_state(key, value), which stages the write in State._pending.
  2. The executor then raises an exception, causing the current superstep to fail.
  3. 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.
  4. The failed run therefore leaves the staged write in State._pending.
  5. The same Workflow instance can be reused for another run() call.
  6. During the later successful run, the runner reaches a superstep boundary and calls State.commit().
  7. 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

Activity

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: PythonreproducedUsage: [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