Skip to content

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

Closed

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

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

    @github-actions
    Contributor

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

    Agent analysis

    The bug reproduces in python/packages/core/agent_framework/_workflows/_runner.py::RunnerImpl.run_until_convergence around lines 137-152 when an executor stages state and then raises; cancellation follows the same non-discarding path. Reuse the workflow for a second successful run without rewriting that key, and _run_core commits the stale pending value when committing per-run kwargs. Minimal repro: run the added test_failed_superstep_state_does_not_leak_into_later_run test.

    • Failing test: python/packages/core/tests/workflow/test_workflow_states.py
    • Files examined: python/packages/core/agent_framework/_workflows/_state.py, python/packages/core/agent_framework/_workflows/_runner.py, python/packages/core/agent_framework/_workflows/_workflow.py, python/packages/core/tests/workflow/test_workflow_states.py, python/packages/core/tests/workflow/test_runner.py, python/packages/core/pyproject.toml
    • Tests run: test_failed_superstep_state_does_not_leak_into_later_run, test_executor_failed_and_workflow_failed_events_streaming, test_runner_accepts_new_run_after_previous_failure
    • 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 25, 2026
  5. Namraa310806 commented on Aug 25, 2026

    @Namraa310806
    ContributorAuthor

    Evan Mattson (@moonbox3) I’ve implemented the proposed approach for #7859 and added the regression test.

    The change uses the existing State.discard() mechanism in RunnerImpl.run_until_convergence():

    • Discard pending state after the iteration task has been cancelled and awaited in the CancelledError path.
    • Discard pending state in the exception path before re-raising.
    • Added a regression test covering a failed run followed by a successful run on the same Workflow instance.

    The focused workflow tests are passing, and the change is limited to _runner.py and the workflow regression test.

    Does this approach look good to you? If so, I’m happy to open a PR for #7859.

  6. added 3 commits that reference this issue on Sep 11, 2026
    6f7973d
    07c4334
    efb2156
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