Repository navigation
Python: fix(core): keep finished background tasks until cleared or released - #9387
Karthik Thota (karthik-0306) wants to merge 8 commits into
Conversation
…leased Part of microsoft#8760 ### Description Keep finished asyncio.Task objects in _RuntimeState.in_flight_tasks inside _finalize_task. They remain in memory until explicitly removed by background_agents_clear_completed_task or torn down via BackgroundAgentsProvider.release_session. ### Bug & Repro In the single-process implementation, _finalize_task extracted results and immediately removed the task from in_flight_tasks. However, host session state is persisted only after the agent run completes. If the host restores a session snapshot taken while a task was RUNNING (e.g. after a run failure, turn rollback, or host desync), subsequent calls to _refresh_task_state found no entry in in_flight_tasks and marked the task as LOST, silently discarding a completed or failed result. ### Trade-off Finished asyncio.Task objects remain referenced in in_flight_tasks until explicitly cleared via clear_completed_task or released via release_session, instead of being discarded immediately at finalization. ### Validation - pytest: 52 passed, 0 failed in 0.77s - ruff check: All checks passed - ruff format: 2 files formatted clean - pyright: 0 errors, 0 warnings, 0 informations - mypy: Success: no issues found
There was a problem hiding this comment.
🟡 Changes recommended
Reused task IDs can associate stale snapshots with results from a later continuation.
1 open finding
What changed in this PR
Retains finished background tasks to reconcile stale single-process session snapshots.
Changes:
- Stops removing tasks during finalization.
- Adds regression and lifecycle cleanup tests.
| File | Description |
|---|---|
_background_agents.py |
Retains finalized tasks and documents cleanup lifecycle. |
test_harness_background_agents.py |
Tests snapshot recovery, failures, cancellation, clearing, and release. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
…execution attribution (microsoft#8760) Address review finding on reusable task IDs in continuation flows: - Track a monotonic generation token on BackgroundTaskInfo and serialize it in to_dict/from_dict. - Dual-key _InFlightTasks with (task_id, generation) so each execution of a continued task is stored discretely. - _refresh_task_state reconciles RUNNING tasks against their specific generation, preventing a restored pre-continuation snapshot from erroneously receiving the result of a later continuation. - Clear and release paths clean up all executions associated with the task ID. - Add regression test test_reused_task_id_does_not_associate_stale_snapshot_with_continuation.
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
Karthik Thota (@karthik-0306) background_agents_clear_completed_task can clear a newer running continuation when the caller restores an older completed generation of the same task id. The restored generation passes the non-running check, but runtime.clear_task(task_id) removes every (task_id, generation) entry and the shared background session. A newer generation can therefore keep running untracked; later refreshes mark it lost, and session release can no longer cancel or drain it.
Please make clear generation-aware so it removes only the completed generation, or explicitly reject/cancel newer running generations before removing their runtime handles. Add a regression that restores generation 1 while generation 2 is still running and then attempts to clear generation 1.
Addressed in 6b90a3d. Generation tracking is removed. background_agents_clear_completed_task now drops the runtime handle and background session only when the local task is done or absent. If a continuation is still running, its handle stays tracked, so release_session(cancel_running=True) can still cancel and drain it. The persisted record is still removed because the caller asked to clear it. Regression added: restore the completed snapshot while the continuation runs, clear, then confirm the continuation is still tracked and release cancels it. |
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
Karthik Thota (@karthik-0306) The final head again keys retained runtime tasks only by task_id. background_agents_continue_task() overwrites that entry for a newer execution, while _refresh_task_state() reconciles any persisted RUNNING record against whichever task currently occupies the ID. A restored stale snapshot can therefore receive the result or error of a later continuation. Please preserve a serialized execution identity/generation (or otherwise prove the retained task is the same execution) and add a stale-snapshot-plus-continuation regression.
Persist a per-task generation so a restored older session snapshot is never reconciled against a later continuation that reused the same task ID. Continue/clear are generation-aware.
|
Eduard van Valkenburg (@eavanvalkenburg) Restored an execution identity. BackgroundTaskInfo now persists a generation (defaults to 1 for older state), and it goes up on every continuation. Runtime records which generation each tracked run belongs to, and refresh, wait and finalize only match a record's own generation. So a restored stale RUNNING snapshot becomes LOST instead of receiving a later continuation's result or error. Continuing from a stale snapshot is rejected while a newer run is still going, and otherwise moves past every generation seen. Clearing only releases runtime state when no newer generation exists. Added regression tests: restore a gen-1 RUNNING snapshot after gen 2 completes or fails, stale wait, and stale continue/clear. |
…hed-tasks-until-cleared # Conflicts: # python/packages/core/agent_framework/_harness/_background_agents.py
|
Eduard van Valkenburg (@eavanvalkenburg) . Merged main to resolve the conflict with #9442. That PR's exact-execution wait handling is kept and now also checks the persisted generation. _finalize_task still keeps finished runs until clear/release, which is the core of this PR, so I updated the one #9442 assertion that expected them to be dropped. Since your last review, 2ac9ae0 adds the persisted per-task generation you asked for, with stale-snapshot + continuation regression tests. Could you take another look, and approve the CI workflows if they need it? Happy to adjust anything. |
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
Karthik Thota (@karthik-0306) Please address the inline generation-consistency race before merge.
|
Eduard van Valkenburg (@eavanvalkenburg) addressed all three. The root causes were the per-task generation counter and the read/clear tools running off the event loop, so I fixed those directly instead of patching each race. The new tests fail on the previous commit. Let me know if you'd like anything changed. |
|
Karthik Thota (@karthik-0306) The current Related Issue wording says |
|
Eduard van Valkenburg (@eavanvalkenburg) fixed, it now says "Part of #8760" with no closing keyword, and the description matches the current code. For Track 2, I'm planning to start with an ADR in docs/decisions (task ownership, outcomes and cancellation across replicas), and could cover Jeffin's case from #9531 too. Once that's agreed I'd open a fresh PR and close #9040. Does that sound right? Should someone from the .NET side join on the ADR? Also, .NET's FinalizeTask has the same issue. I'd like to send a separate PR for it once this one merges, if that works for you. |
|
Karthik Thota (@karthik-0306) There is a separate timeout problem in |
|
Eduard van Valkenburg (@eavanvalkenburg) done. _drain_runtime now uses asyncio.wait(pending, timeout=remaining), so release returns at the deadline even if a task keeps catching cancellation. Tasks still running are abandoned with the existing done-callback, so their later exceptions are retrieved. Added a regression with a task that suppresses every cancellation; it fails on the previous commit and passes now. This also fixes #9231, so cc westey (@westey-m) and Charan Rathore (@charan-rathore) , who were looking at it. |
|
Karthik Thota (@karthik-0306) There is an exception-observation regression in the new Please retrieve the completed tasks' exceptions before returning on timeout, retain the callbacks for unfinished tasks, and add a mixed-outcome regression. |
|
Eduard van Valkenburg (@eavanvalkenburg) good catch,
Added regressions for the mixed-outcome case, a cancelled release, and an abandoned task that only awaits an unreferenced future. Each fails on the previous commit and passes now. |

Motivation & Context
Why is this change required?
In the single-process
BackgroundAgentsProvider, the host persists session state only after an agent run completes._finalize_taskused to drop a finished task from_RuntimeState.in_flight_tasks. If the host then restored a snapshot taken while that task wasRUNNING(after a failed run, a rollback, or a state reload),_refresh_task_statecould no longer find it and marked itLOST, discarding a result that was already available.What problem does it solve?
Finished executions stay in memory until they are cleared or the session is released, so a stale
RUNNINGrecord can still pick up its real result or error. Because task IDs are reused by continuations and can be reused after a rollback, every execution also gets a persisted identity, so a record can never pick up a different execution's outcome. Separately,release_session(timeout=...)now returns at its deadline even if a task keeps suppressing cancellation.What scenario does it contribute to?
Single-process background agents used across multi-turn sessions where host persistence timing, rollbacks, or failed turns would otherwise orphan completed tasks, and hosts that rely on a bounded session release during eviction or shutdown.
Issue Link:
Part of [Feature]: BackgroundAgentsProvider cannot tell a task that is running on another process from one whose owner died #8760 (Track 1: single-process retention). Also covers Python: [Bug]: Background session release waits past its finite timeout during cancellation cleanup #9231.
Description & Review Guide
What are the major changes?
_finalize_taskno longer removes finished executions. They are removed bybackground_agents_clear_completed_taskorrelease_session.BackgroundTaskInfopersists anexecution_id(a new uuid for every start and continuation). Runtime lookups only match a record's own execution, so a stale snapshot becomesLOSTinstead of receiving another execution's result or error. State saved before this change loads with noexecution_id._drain_runtimeobserves cancelled tasks withasyncio.wait(pending, timeout=remaining)instead ofasyncio.wait_for(gather(...)), so release returns at the deadline even if a task keeps catchingCancelledError. Tasks still running are abandoned with the existing done-callback, which retrieves their eventual exceptions. This was a pre-existing issue, not caused by the retention change.execution_id. One of its assertions expected finished tasks to be dropped and was updated for the new retention behavior.What is the impact of these changes?
LOSTresults after snapshot restores in single-process hosts.release_session(timeout=...)can no longer be held open indefinitely by a task that ignores cancellation.asyncio.Taskreferences stay in memory until the caller clears the task or the session is released.What do you want reviewers to focus on?
execution_idmatching in_RuntimeState.get_executionand its use in refresh, wait, continue and clear._drain_runtime.Validation
toolimport, which is also reported without this change.Related Issue
Part of #8760. This PR covers Track 1 only. #8760 should stay open for the Track 2 cross-replica design. Also covers the release-timeout bug reported in #9231.
AI Assistance
AI assistance details:
AI assisted with drafting code and regression tests, running validation checks (pytest, ruff, mypy, pyright, pyrefly, ty), and drafting this PR description.
Contribution Checklist