Repository navigation
[Feature]: BackgroundAgentsProvider cannot tell a task that is running on another process from one whose owner died #8760
Description
Description
Problem. BackgroundAgentsProvider splits a background task into two halves. The record (BackgroundTaskInfo: id, status, description, result_text, error_text) is serialized into session.state, so it travels with the session to whichever process loads it. The runtime (_RuntimeState.in_flight_tasks, the asyncio.Task objects, and _RuntimeState.background_sessions, the child AgentSessions) lives in self._runtime, a process-local dict keyed by session_id. The comment in BackgroundAgentsProvider.__init__ says this runtime "cannot survive process restarts. If the provider instance is lost, _refresh_task_state() marks orphaned tasks as LOST." _refresh_task_state does exactly that for any RUNNING record without a local in-flight task, and then saves the LOST status back into session state. before_run calls _refresh_task_state on every turn, before the model sees anything.
That is correct when the process that started the task is dead. It is wrong when that process is alive and simply is not the one serving this turn, and the provider has no way to tell the two apart. Tasks outliving a run are also a case the provider supports: before_run refreshes "to get accurate statuses for any tasks that completed between turns". So the question is not whether a task can span turns, but whether the next turn lands on the same process.
Our deployment: a web host running two replicas behind a load balancer without session affinity, with conversation state in Redis. Any turn can land on either replica. agent-framework-core: 1.19.0 (tracking main).
Scenarios that fail today
- Healthy task marked LOST, then duplicated. Turn N runs on process A; the model calls
background_agents_start_task, and the run ends while the task is still running (see scenario 3 for how). Turn N+1 is routed to process B. B loads the session,before_run→_refresh_task_statefinds the taskRUNNINGwith no entry in B'sin_flight_tasks, marks itLOSTand persists that. The model's injected status block now readsTask 1 [lost];background_agents_get_task_resultsreturns "Task state was lost (reference unavailable)." The model does the reasonable thing and starts the same work again as task 2 on B. Meanwhile task 1 is still running on A. Two copies of the same delegated work now run concurrently against the same tools, possibly writing the same files, and the user pays for both. - The original's result is orphaned. When task 1 finishes on A, nothing records it: finalization only happens inside a run on A, and on A's next turn for this session
_refresh_task_stateskips the record because the persisted status is nowLOST, notRUNNING(the loopcontinues on anything that isn'tRUNNING). The completed work is never delivered. It sits in A'sin_flight_tasksuntilrelease_sessionor process exit. - Runs end with live tasks despite the instruction.
DEFAULT_BACKGROUND_AGENTS_INSTRUCTIONSsays "Always wait for outstanding tasks to finish before you finish processing." That is a request to the model, and several paths end the run no matter what the model intends. (a) A tool that requires approval ends the run with a pending approval request, and the user may answer hours later, typically on a different process. (b)max_function_callssetstool_choice="none"(_disable_tools_at_function_call_limit), andmax_duration_secondstakes the same path, so the model cannot callbackground_agents_wait_for_first_completioneven if it wants to. (c) Provider errors abort the run. (d) The user presses stop. Each of these leavesRUNNINGrecords in session state, and on a multi-instance host each one becomes scenario 1 on the next turn with probability roughly (N-1)/N. background_agents_continue_taskfails across processes. Even when a task completed and was finalized on A (so its record isCOMPLETED,result_textpersisted, and results are readable anywhere), continuing it on B returns "Error: Session for task 1 is no longer available." The childAgentSessionexists only in A'sbackground_sessions.- Restarts and rolling deploys. Same sequence as scenario 1, except A is gone. Here
LOSTis the right answer. We are not asking to hide it. The point is that the provider currently reaches the same verdict for "owner dead" (correct) and "owner alive elsewhere" (wrong), because the only signal it has is its own process's memory. release_sessionis process-local. A host that deletes a conversation callsrelease_sessionon whichever process handles the delete. If the tasks run on another process, it finds no runtime and returns without doing anything. The tasks keep running, and keep calling tools, for a conversation that no longer exists.- Waiting and listing cannot see other processes. On B,
background_agents_wait_for_first_completion([1])finds nothing inin_flight_tasks, refreshes (markingLOST), and returns "Task 1 is not running; current status: lost."background_agents_get_all_tasksreports the same. B cannot observe a task on A at all, so it cannot even choose to wait for it.
Expected behavior. A host with a shared store should be able to give the provider enough information to (a) mark LOST only when the owning process is actually gone, (b) report a task running on another instance as running, not lost, (c) deliver a task's result from whichever process serves the next turn, and ideally (d) signal cancellation to the owner. Hosts without a shared store keep today's single-process behavior unchanged.
Proposed shape (upstream's call). A small pluggable seam on BackgroundAgentsProvider, e.g. a BackgroundTaskRuntimeStore / liveness protocol with an in-memory default that reproduces current semantics:
- The host provides an opaque qualified session key (namespace + session identity) and the owner process records a lease per
(qualified_session_key, task_id)when it starts or continues a task, renews it while the task runs, and clears it on finalization. _refresh_task_stateasks the store before markingLOST: lease present and fresh → leaveRUNNING(or a distinct status such asRUNNING_ELSEWHERE, which the tools render as "running on another instance"); lease missing or expired →LOSTas today.- When a task finishes, the owner writes its outcome (status,
result_text/error_text) to the store from a done-callback, not only inside a later run. Any process's_refresh_task_statethen picks it up, which fixes scenario 2 and makeswait_for_first_completionable to poll a remote task instead of returning immediately. - Optionally, a cancel signal the store propagates to the owner, so
release_sessionon one process cancels tasks owned by another (scenario 6). - Continuing a task on a non-owner (scenario 4) is harder, since it needs the child session. Serializing the child
AgentSessioninto the store on finalization would cover it where the child agent's session is serializable. Otherwise a clear "continue is only available on the instance that ran the task" error is still better than today's generic message.
Nothing here requires the framework to ship a distributed store. A protocol plus the in-memory default is enough; hosts bring Redis or anything else. A reference Redis implementation would fit naturally next to the existing agent-framework-redis package, but that is optional.
Alternatives considered
- Rely on "always wait before finishing". This is an instruction, not enforcement, and scenario 3 lists four paths where the model cannot comply. The provider itself already expects tasks to span turns.
- Enforce it: cancel or await outstanding tasks at run end. Awaiting blocks the run, and so the user, for the full task duration, which defeats the point of background work. Cancelling at an approval interrupt throws away delegated work right before the user approves the step that needed it.
- Session affinity at the load balancer. Fixes scenarios 1, 2, 4 and 7 while every process is up, but breaks exactly on restarts and rolling deploys, and many hosts (serverless containers, some managed ingresses) cannot pin reliably. It also does nothing for scenario 6 when the delete request lands elsewhere.
- Durable execution (Durable Agents). Solves durability for a different hosting model and is much heavier than a liveness lease. It does not integrate with
BackgroundAgentsProvider, which is what harness-based hosts use. - Host-side wrapper. Possible, but only by reaching into the private
_runtime/_RuntimeState/_refresh_task_state, which every framework bump can break. The decision point (_refresh_task_statechoosingLOST) is inside the provider, so the seam belongs there.
Code Sample
from typing import Protocol
class BackgroundTaskRuntimeStore(Protocol):
async def acquire(self, qualified_session_key: str, task_id: int, *, ttl_seconds: float) -> None: ...
async def renew(self, qualified_session_key: str, task_id: int, *, ttl_seconds: float) -> None: ...
async def is_alive(self, qualified_session_key: str, task_id: int) -> bool: ...
async def publish_outcome(self, qualified_session_key: str, task_id: int, info: BackgroundTaskInfo) -> None: ...
async def fetch_outcome(self, qualified_session_key: str, task_id: int) -> BackgroundTaskInfo | None: ...
async def request_cancel(self, qualified_session_key: str) -> None: ...
provider = BackgroundAgentsProvider(
agents=[researcher, writer],
runtime_store=MyRedisBackgroundTaskRuntimeStore(redis), # default: in-memory, today's behavior
)Language/SDK
Both
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
karthik-0306 commentedon Sep 25, 2026
Eduard van Valkenburg (@eavanvalkenburg) , I dug into this and can confirm the race exists exactly as described in
_refresh_task_state(_background_agents.py, lines ~235–260):in_flightis looked up only in the process-local_RuntimeState.in_flight_tasks, so anyRUNNINGrecord with no local entry gets markedLOSTregardless of whether the owning process is dead or just not the one serving this turn. I traced all seven scenarios against the current code and they all check out.I'd like to take this on, implementing the
BackgroundTaskRuntimeStoreprotocol as proposed:Implementation plan:
BackgroundAgentsProvider.__init__gains an optionalruntime_store: BackgroundTaskRuntimeStore | None = None, defaulting to an in-memory implementation that reproduces today's single-process semantics exactly — no behavior change for hosts that don't configure one. This mirrors the existingSessionStoreprotocol already in_sessions.py, so aRedisBackgroundTaskRuntimeStorecould sit alongsideRedisStoreinagent-framework-redis.background_agents_start_taskcallsacquire()on start; a wrapper coroutine around_run_agent(...)renews the lease on a configurable interval (defaulting tottl_seconds / 2) for the life of the task, rather than a separate heartbeat task, so renewal is tied to the task's own lifecycle. A done-callback on the underlyingasyncio.Taskcallspublish_outcomethe instant the task settles, from whichever process owns it — today_finalize_taskonly runs inside a run on the owner, and never at all once a record's been markedLOST, so this is what actually delivers the orphaned result._refresh_task_statecallsis_alive()before markingLOST: lease present and fresh → leaveRUNNING; lease missing/expired → checkfetch_outcome()and finalize from that if present,LOSTotherwise, same as today.release_sessioncallsrequest_cancel()before the current early return atself._runtime.get(session_id) is None(lines 374–378) — today that early return is exactly why a delete on a non-owning process silently no-ops, so the cancel call has to happen ahead of it, not alongside it.request_cancel(qualified_session_key)is session-granular by design, matchingrelease_session's own scope (cancel everything for a session, not one task). Happy to add an optionaltask_id: int | None = Noneif partial cancellation turns out to matter.continue_taskon a non-owner, since the childAgentSessionisn't serialized into the store here, the error becomes"Task {task_id} was completed on another instance; continue is not available cross-process in this release."— more specific than today's generic "session no longer available" message.qualified_session_keywill besession.session_idused as-is, with no built-in namespacing in the protocol — hosts running multiple namespaced environments would wrap or subclass the store rather than the protocol handling it. Flagging this as an open question below rather than assuming it.fetch_outcome/publish_outcomereturn and acceptBackgroundTaskInfodirectly, which couples any external store implementation (Redis, etc.) to importing that framework class. This is the same shapeSessionStorealready uses withAgentSession, so it's precedented, but worth naming sinceBackgroundTaskInfois currently@experimental.A few things I'd like alignment on before opening the draft PR:
qualified_session_keycomposition — I plan to usesession.session_iddirectly, with no namespacing built into the protocol itself. Is that the right default, or should__init__also accept something likesession_key_prefix: str = ""or a key factory for hosts that need namespacing?renewvia a wrapper coroutine around_run_agent(...)rather than a separate heartbeat task, so renewal lifecycle is tied to the task itself. Does that match what you had in mind, or would you rather see an independent heartbeat loop?request_cancelgranularity — the protocol as sketched cancels everything for a session key.release_sessionwill call it ahead of the current early-return so the signal reaches the owner even when this process has no local runtime. Let me know if task-granular cancellation is needed instead.time.monotonic()is process-local and unusable across processes, sotime.time()is the only semantically correct option here; flagging this as confirmation rather than an open preference, unless you'd rather the store manage its own timestamps.Happy to open a draft PR once we're aligned on these four.
eavanvalkenburg commentedon Oct 14, 2026
/plan I want to split this into two pieces 1) fix any issues with the existing and 2) start creating a design for broader ways of doing this, which might include the proposed protocol but might also leverage other infrastructure and so needs to be even more generic and shared across .Net and python
github-actions commentedon Oct 14, 2026
Summary
I read the full issue, the proposed
BackgroundTaskRuntimeStoreprotocol from the earlier comment, and the current code in_harness/_background_agents.py. I independently traced the seven failure scenarios against the code rather than taking the earlier analysis at face value, and they hold up:_refresh_task_state(around line 243) only checks the process-localruntime.in_flight_tasksdict. If aRUNNINGtask isn't found there, it is unconditionally markedLOST, with no way to distinguish "owner process died" from "owner process is alive but didn't serve this turn."before_runcalls this on every turn (near the end of the file), so this runs before the model sees anything._finalize_task(the function that records a completed result) is only reachable from code paths running on the same process that started the task (background_agents_wait_for_first_completion, or_refresh_task_statefinding a locally-tracked task done). Once a record has been markedLOSTelsewhere, thecontinueat the top of the refresh loop means that process's own later finalization never updates it — exactly scenario 2.release_session(around line 345) returns immediately if there is no local runtime for the session — exactly scenario 6, the cross-process delete no-op.background_agents_continue_taskdepends onruntime.background_sessions.get(task_id), a process-local dict of childAgentSessionobjects — exactly scenario 4.max_function_calls/max_duration_secondsdo disable tools (_tools.py_disable_tools_at_function_call_limit), so a model mid-way through background delegation can have its tool access cut off while tasks are stillRUNNING— supporting scenario 3(b).I also confirmed .NET has a parallel, independently-implemented provider (
dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProvider.cs,BackgroundAgentRuntimeState.cs) with the same per-process runtime-state shape, so this is not Python-only, which supports your framing that a shared design matters.I did not look at the open PR's (#9040) implementation or diff, per the workflow's instructions; my assessment is based only on the current
maincode.Recommendation
Splitting this into two tracks makes sense, and both are warranted — but for different reasons and with different scopes.
Track 1 — fix bugs in the existing single-process semantics. Independent of any cross-process design, the current code has at least one bug with no multi-process angle at all: scenario 2 (orphaned result never delivered) can happen on a single long-running process too, any time
_refresh_task_statehas already stamped a taskLOSTfor some other reason and a later finalization is silently dropped by thecontinue. That's a plain logic defect worth fixing on its own, with a regression test, regardless of what the broader design becomes.Track 2 — a shared, generic design for cross-process/distributed task ownership. This is appropriately scoped as a design exercise, not a direct implementation of the
BackgroundTaskRuntimeStoreprotocol as proposed. Reasons:asyncio.Task,AgentSessionas a direct payload type) and would need deliberate rethinking to also fit .NET'sBackgroundAgentRuntimeState.cs/BackgroundAgentsProvider.cs, which has its own task/session model.SessionStore/FileSessionStoreand checkpoint/session-store work already address elsewhere in the framework (see_sessions.pySessionStoreand ADR0039-shared-agent-session-store.md). A design pass should check whether a generic "ownership/liveness lease" concept is better placed as shared infrastructure other providers could reuse, rather than a one-off protocol bolted ontoBackgroundAgentsProvider.@experimentalclass (BackgroundTaskInfo/BackgroundAgentsProvider), so the design should reach a documented decision (likely an ADR, per this repo'sdocs/decisions/process) before an implementation PR is accepted, rather than deciding the shape via a single contributor PR.Proposed plan
Track 1 — targeted bug fix (Python, and check .NET for the analogous issue):
_refresh_task_stateso a task whose status was previously set toLOST(or any non-RUNNINGterminal status) doesn't permanently block a later finalization from a still-live owner process — e.g., don'tcontinuepast tasks that are locally in-flight and done, regardless of their currently persisted status.test_harness_background_agents.pycovering: a task finalizing after being markedLOSTon the same process, and confirmbackground_agents_get_task_resultsreturns the real result/error rather than "state was lost" once finalized.dotnet/src/Microsoft.Agents.AI/Harness/BackgroundAgents/BackgroundAgentsProviderTests.cshas the same gap and needs a parallel fix.mainand pass after the fix; existing tests for single-processLOSTbehavior (owner genuinely gone) keep passing unchanged.Track 2 — generic cross-process design (no code yet):
docs/decisions/, followingadr-template.md/adr-short-template.md) that frames the problem independent of the specific protocol already proposed: what "task ownership," "liveness," and "result handoff" need to mean generically, and where that concept should live (owned byBackgroundAgentsProvideralone vs. shared withSessionStore/session-state infrastructure).BackgroundTaskRuntimeStoreprotocol as one option, alongside alternatives such as representing task ownership purely through existing session/state primitives, or a narrower "liveness lease" abstraction usable by both languages.This is a proposal only — no code, tests, or ADR have been written yet.
karthik-0306 commentedon Oct 14, 2026
Thanks Eduard van Valkenburg (@eavanvalkenburg), and thanks for the plan. I checked both tracks against current main.
Track 1: there is a real single-process gap. _finalize_task updates the record and removes the task from in_flight_tasks, but the session state is persisted by the host only after the run completes. If the run then fails, is stopped, or is resumed from an older snapshot, the persisted record still says running while the in-memory task is gone, so the next refresh marks it lost and drops a result that already exists. I reproduced it with a snapshot taken before finalization: main returns "Task state was lost", and keeping finished tasks in in_flight_tasks until clear_completed_task or release_session returns the real result, with the existing 47 tests passing. The trade-off is that finished Task objects stay in memory until cleared or released. I'd like to open this as a small Track 1 PR (marked as part of #8760, not closing it) with a regression test, if you agree. .NET has the same pattern in FinalizeTask, so it may need a parallel fix.
Track 2: .NET reproduces all seven scenarios, I found no existing lease or ownership abstraction in either language, ADR-0039 covers the .NET session store only, and the persisted task records differ between languages (casing and status values). I'd suggest the ADR define a language-neutral contract for the store entries (lease and outcome), not for the session record. I'm glad to draft it, with #9040 as a reference prototype, ideally with a .NET co-author. Should I mark #9040 as a draft meanwhile so reviewers don't treat it as a merge candidate?
Python: Record generation token on background tasks to prevent wrong …
Python: Simplify background task retention without generation tracking (
Python: Track execution generation on background tasks (microsoft#8760)
JeffinSiby commentedon Oct 19, 2026
Eduard van Valkenburg (@eavanvalkenburg) Since the broader design here is meant to be shared across .NET and Python,
MessageInjectingChatClientalso has a process-local state that I think should also be addressed.Its pending queue lives in
AgentSession.StateBag, guarded by a per-processConditionalWeakTable<AgentSession, SemaphoreSlim>, and the class is sealed. In a host with several replicas and no session affinity, the run streams from one replica while the user's mid-run message can arrive at another in a load-balanced architecture.EnqueueMessagesAsyncon the receiving replica only reaches a deserialized copy of the session, so the live run never sees the message.It would help if the design covered injected-message delivery as well as background-task runtime state, for example:
MessageInjectingChatClientdrains before each inner call, alongside its in-memory queue, so a shared store can feed it.Happy to contribute the .NET side of (1) and (2) once the shape is agreed.