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_state finds the task RUNNING with no entry in B's in_flight_tasks, marks it LOST and persists that. The model's injected status block now reads Task 1 [lost]; background_agents_get_task_results returns "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_state skips the record because the persisted status is now LOST, not RUNNING (the loop continues on anything that isn't RUNNING). The completed work is never delivered. It sits in A's in_flight_tasks until release_session or process exit.
- Runs end with live tasks despite the instruction.
DEFAULT_BACKGROUND_AGENTS_INSTRUCTIONS says "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_calls sets tool_choice="none" (_disable_tools_at_function_call_limit), and max_duration_seconds takes the same path, so the model cannot call background_agents_wait_for_first_completion even if it wants to. (c) Provider errors abort the run. (d) The user presses stop. Each of these leaves RUNNING records 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_task fails across processes. Even when a task completed and was finalized on A (so its record is COMPLETED, result_text persisted, and results are readable anywhere), continuing it on B returns "Error: Session for task 1 is no longer available." The child AgentSession exists only in A's background_sessions.
- Restarts and rolling deploys. Same sequence as scenario 1, except A is gone. Here
LOST is 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_session is process-local. A host that deletes a conversation calls release_session on 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 in in_flight_tasks, refreshes (marking LOST), and returns "Task 1 is not running; current status: lost." background_agents_get_all_tasks reports 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_state asks the store before marking LOST: lease present and fresh → leave RUNNING (or a distinct status such as RUNNING_ELSEWHERE, which the tools render as "running on another instance"); lease missing or expired → LOST as 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_state then picks it up, which fixes scenario 2 and makes wait_for_first_completion able to poll a remote task instead of returning immediately.
- Optionally, a cancel signal the store propagates to the owner, so
release_session on 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
AgentSession into 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_state choosing LOST) 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
Description
Problem.
BackgroundAgentsProvidersplits a background task into two halves. The record (BackgroundTaskInfo: id, status, description,result_text,error_text) is serialized intosession.state, so it travels with the session to whichever process loads it. The runtime (_RuntimeState.in_flight_tasks, theasyncio.Taskobjects, and_RuntimeState.background_sessions, the childAgentSessions) lives inself._runtime, a process-local dict keyed bysession_id. The comment inBackgroundAgentsProvider.__init__says this runtime "cannot survive process restarts. If the provider instance is lost, _refresh_task_state() marks orphaned tasks as LOST."_refresh_task_statedoes exactly that for anyRUNNINGrecord without a local in-flight task, and then saves theLOSTstatus back into session state.before_runcalls_refresh_task_stateon 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_runrefreshes "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
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._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.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.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.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
LOSTonly 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. aBackgroundTaskRuntimeStore/ liveness protocol with an in-memory default that reproduces current semantics:(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.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.release_sessionon one process cancels tasks owned by another (scenario 6).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-redispackage, but that is optional.Alternatives considered
BackgroundAgentsProvider, which is what harness-based hosts use._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