fix(run): work on copies of the lists a resumed run adopts from RunState - #4251
Merged
Conversation
seratch
approved these changes
Aug 7, 2026
seratch
enabled auto-merge (squash)
August 7, 2026 00:36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Component:
src/agents/run.py—AgentRunner.run/AgentRunner.run_streamed, the resumed-RunStatesetup.Problem. When a run is resumed from a
RunState, the runner adopts three of the state's lists by reference and then appends to them for the rest of the run.RunStateis the durable pause/resume boundary and the caller still owns it, so running from a checkpoint silently rewrites that checkpoint — including its serialized form.The two entry points corrupt different fields, so the damage differs by path:
Runner.runRunner.run_streamed_model_responsesrun.py:695)_session_itemsrun.py:2010)_generated_itemsrun.py:693)Because history accumulates in the checkpoint, a second run from that same checkpoint replays work that does not belong to it. On the streamed path that leaks into the model input:
Root cause.
session_itemswas already taken aslist(run_state._session_items)on the non-streamed path; its two neighbours in the same block were not, and the streamed constructor passesrun_state._session_itemsstraight intoRunResultStreaming.new_items. Every downstream write-back to the state is an explicit assignment (update_run_state_for_interruption, andrun_state._session_items = list(streamed_result.new_items)inrun_loop.py), so nothing depended on the sharing — the aliases only ever leaked appends backwards.This is the same defect #4237 fixed in the other direction (
state._model_responses = list(result.raw_responses)when building a state); these are the three remaining sites, on the path that consumes one.Fix + why minimal. Take a copy of each list the resumed run adopts, matching what
session_itemsalready did. Threelist(...)calls, no new helper, no behavior change for a run that starts without a state, and no change to the serializedRunStateshape — so no schema version bump.Deliberately not changed:
raw_responses=run_state._model_responsesin theRunResultStreamingconstructor. It is the visible sibling of thenew_itemsline, but the streamed loop rebinds (streamed_result.raw_responses = streamed_result.raw_responses + [...]) rather than appending, so copying it is provably inert — I verified by reverting each of the four candidate lines individually and rerunning the focused tests: the three in this patch each fail a distinct test, that one changes nothing. It is left alone rather than added as no-op churn.Non-goals. The runner also writes scalar bookkeeping back onto a resumed state (
_current_step,_current_turn,_original_input, and the explicitupdate_run_state_for_interruptionsync). That is deliberate runner behavior and out of scope here; this PR only stops history from accumulating in the caller's lists.Test plan
Four tests in
tests/test_run_state.py::TestRunStateResumption, next to the#4237test they mirror. All go through the publicRunner.run/Runner.run_streamedAPI withFakeModel— no API key, no network, no sleeps.test_resume_does_not_append_to_the_state_it_resumed_from— non-streamed:_model_responsesand the serializedmodel_responsesare unchanged by a resume, and a second run from the same checkpoint returns only its own two responses.test_streamed_resume_does_not_append_to_the_state_it_resumed_from— streamed: same for_session_items/ serializedsession_items, plusto_input_list()on a re-run contains 3 items rather than 4.test_resumed_max_turns_handler_does_not_append_to_state_items— the_generated_itemssite, reached by resuming a state whosemax_turnsis already spent with amax_turnserror handler installed.test_fresh_runs_still_report_their_own_history— boundary: a non-resumed run (both paths) still reports exactly its own response and item. Passes before and after, proving the copies did not change the fresh-run path.The first three fail on clean
upstream/main@f3b6c617(3 failed, 2 passedfor the selection, counting the pre-existing#4237test); all 5 pass after. Red/green was also proved in place by reverting only the source lines. Focused selection repeated 5× under-W error::RuntimeWarning: stable, no pending-task or unclosed warnings.Exact commands and results:
uv run pytest tests/test_run_state.py -q -k "does_not_append or fresh_runs_still_report or does_not_mutate_source_result"— red3 failed, 2 passed, 238 deselected; green5 passed, 238 deselected(×5 runs).uv run pytest tests/test_run_state.py -q—243 passed.uv run pytest tests/test_run_state.py tests/test_agent_runner_streamed.py tests/test_hitl_error_scenarios.py tests/test_hitl_session_scenario.py tests/test_stream_events.py tests/test_max_turns.py tests/test_cancel_streaming.py tests/test_streamed_terminal_output_backfill.py -q—446 passed.env UV_DEFAULT_INDEX=https://pypi.org/simple bash .agents/skills/code-change-verification/scripts/run.sh— all commands passed (make format862 files unchanged,make lintall checks passed,make typecheckpassed in 223s,make testspassed in 63s).make typecheck— mypySuccess: no issues found in 849 source files, pyright0 errors, 0 warnings, 0 informations.make tests—6745 passed, 29 skipped(parallel) and77 passed, 5 skipped, 57 deselected(serial).git diff --check— clean.Not run:
make coverage,make build-docs(no docs touched), and the Python 3.10 matrix environment.Issue number
None — reporting and fixing together, as the fix is three lines.
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR