Skip to content

Python: fix(core): keep a dropped run's late finalizer from discarding the next run's state writes - #9477

Open
Yufeng He (he-yufeng) wants to merge 2 commits into
microsoft:mainfrom
he-yufeng:fix/runner-stale-finalizer-discard
Open

Yufeng He (he-yufeng) wants to merge 2 commits into
microsoft:mainfrom
he-yufeng:fix/runner-stale-finalizer-discard

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Why

Fixes #9473. Dropping a workflow stream mid-superstep and starting a new run on the same Workflow before the dropped stream's async-generator finalizer executes silently loses the new run's state writes: the stale finalizer's State.discard() clears the shared pending map, which by then holds the successor's writes. The successor still finishes IDLE with its outputs; the write is just gone.

What changed

Two cooperating pieces, matching the identity-check defense _run_core already uses for _active_run:

  • Runner stamps each drive of run_until_convergence with an epoch, and the superstep finally only discards pending writes while it is still the latest drive. A stale finalizer (the successor has already bumped the epoch) leaves the pending map alone.
  • Workflow.run() sweeps a dropped predecessor's leftover pending writes synchronously, next to the existing stale runtime-checkpoint-storage clearing. This has to happen in run(), not at the runner's entry: _run_workflow_with_tracing commits this run's kwargs before the runner loop starts, and without the sweep that commit would fold the predecessor's leftover writes into the committed state.

Verification

  • New test_workflow_dropped_stream_finalizer_preserves_successor_state_writes in test_workflow.py, following the reported repro's exact timing (iterate run A's stream to executor_invoked, drop + gc.collect, start run B before the finalizer runs, let the finalizer run, then finish B). It asserts B's write survives, B's outputs and IDLE state are intact, and A's never-finished write is swept rather than committed. Fails on unpatched main with exactly the reported symptom (b_key missing); passes here.
  • A stack-traced probe of State.commit confirms the ordering: the successor's kwargs commit no longer carries the abandoned a_key, and the stale finalizer performs no discard.
  • Regression: 247 passed across test_workflow.py, test_runner.py, test_state.py, test_workflow_states.py and the checkpoint storage/conformance files; ruff check/format clean; pyright on the two touched modules shows no new errors (the pre-existing baseline is unchanged).

Verified locally on macOS, Python 3.14.2, CPU only.

Copilot AI 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.

🟡 Changes recommended

Remaining ownership races can commit abandoned cleanup writes or discard successor checkpoint snapshots.

2 open issues
What changed in this PR

Addresses #9473 by protecting successor workflow state from a dropped stream’s late finalizer.

Changes:

  • Guards runner cleanup with a per-drive epoch.
  • Clears abandoned pending writes before successor setup.
  • Adds a regression test for delayed stream finalization.
File Description
python/​packages/​core/​tests/​workflow/​test_workflow.py Tests successor writes, outputs, status, and abandoned-write removal.
python/​packages/​core/​agent_framework/​_workflows/​_workflow.py Sweeps pending state at run entry.
python/​packages/​core/​agent_framework/​_workflows/​_runner.py Guards pending-state discard with an epoch check.

Review effort: Balanced

Comment on lines +124 to +125
self._run_epoch += 1
my_epoch = self._run_epoch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in c4d09dd, and the mechanism moved earlier than you suggested. The epoch is now claimed by Runner.begin_run() from Workflow.run()'s synchronous section, before any await point, so by the time _save_executor_states() stages the first snapshot the stale finalizer's epoch check already fails. run_until_convergence reads the epoch instead of advancing it. Regression pin: test_workflow_run_advances_runner_epoch_synchronously asserts the epoch has advanced before run() returns (red if the claim moves back into the generator).

# once this run takes over). Sweep them here, before this run stages and
# commits its own run kwargs, so the leftover writes are neither kept
# nor folded into this run's first commit (#9473).
self._runner.state.discard()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partially addressed in c4d09dd, with the residual stated honestly. _run_core now yields once at its start so the loop can drive the already-scheduled finalizer, then sweeps the pending map again before this run stages anything; a regression test (..._cancellation_cleanup_writes_do_not_leak) covers the dominant interleaving where the predecessor's finally-block write lands before the successor's sweep. The residual window you describe is real: a finalizer slower than that single yield can still leak a cleanup write into the successor's first commit. Closing it for good needs per-run isolation of pending writes (keying the pending map by run epoch, or draining the predecessor through a tracked handle), which is a bigger State-layer change than this PR's scope; I would rather land that separately if you agree it is worth doing.

Yufeng He (he-yufeng) added a commit to he-yufeng/agent-framework that referenced this pull request Oct 18, 2026
Copilot review on microsoft#9477 found the epoch advanced too late:
run_until_convergence is an async generator whose entry code runs on the
first pull, but the successor's entry checkpoint stages executor snapshots
before that, so a dropped run's finalizer could still match the epoch and
discard the staged snapshots mid-flight. The epoch now advances in
Workflow.run()'s synchronous section via Runner.begin_run(), before any
await point, and the runner's loop reads it.

Also sweep once more after a loop yield at _run_core's start: a dropped
predecessor whose handler writes state from a cancellation cleanup path
can otherwise land those writes after the synchronous sweep. A residual
window remains for a finalizer slower than that yield; per-run state
isolation would close it for good.
…g the next run's state writes

A dropped workflow stream's async-generator finalizer runs after the
caller has already started a successor run on the same instance. The
superstep cleanup then discarded the shared State's pending writes,
which by then belonged to the successor: the new run still finished
IDLE with its outputs, but its set_state writes were silently gone
(microsoft#9473).

The runner now stamps each drive with an epoch and only discards
pending writes while it is still the latest drive, and run() sweeps a
dropped predecessor's leftover pending writes up front, next to the
existing stale runtime-checkpoint-storage clearing, so they are neither
kept nor folded into the successor's first kwargs commit.
Copilot review on microsoft#9477 found the epoch advanced too late:
run_until_convergence is an async generator whose entry code runs on the
first pull, but the successor's entry checkpoint stages executor snapshots
before that, so a dropped run's finalizer could still match the epoch and
discard the staged snapshots mid-flight. The epoch now advances in
Workflow.run()'s synchronous section via Runner.begin_run(), before any
await point, and the runner's loop reads it.

Also sweep once more after a loop yield at _run_core's start: a dropped
predecessor whose handler writes state from a cancellation cleanup path
can otherwise land those writes after the synchronous sweep. A residual
window remains for a finalizer slower than that yield; per-run state
isolation would close it for good.
@he-yufeng
Yufeng He (he-yufeng) force-pushed the fix/runner-stale-finalizer-discard branch from c4d09dd to e9ed684 Compare October 21, 2026 13:26
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (0200599, which includes #8819) — the two changes interleave cleanly.

Worth stating the relationship precisely, since #8819 works the same failure-adjacent area: it discards pending state when a superstep fails (the committed flag in the superstep loop). The defect here is the mirror image and is still live on current main: when a dropped stream's finalizer runs late, it takes the not committed path in that same finally and discards pending writes that by then belong to the successor run. The epoch guard in this PR is what distinguishes the two runs' writes.

Re-verified after the rebase: tests/workflow/test_workflow.py 66 passed, including the three new tests; on unpatched main the headline test fails functionally (run B converges IDLE but its b_key write is silently gone while run A's stale key lingers), which is the exact loss this PR prevents.

This branch was successfully deployed

1 active deployment
github-app-auth — e9ed6844 Deployed Oct 21, 2026 by he-yufeng via add_label #25857
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: Next workflow run loses its state writes when a dropped run is finalized late

2 participants