Skip to content

Commit b22a68d

Browse files
feat(server): release parked interrupted tasks after a configurable timeout (#1338)
## Description Second half of #1296, following the direction agreed in #1296 (comment) and the maintainer's reply. A task in `input-required` or `auth-required` keeps its `ActiveTask` (producer, consumer, two dispatchers, registry entry) until a follow-up arrives, which may never happen. ### Change - New `DefaultRequestHandlerV2(..., interrupted_task_timeout: float | None = None)`. The default `None` keeps today's behavior. - When the timeout is set, `ActiveTask` starts a timer once a request finishes or the last subscriber leaves. A new request or subscriber cancels it. When it fires, the `ActiveTask` is released only if all of these still hold: - the task is in an interrupted state, - no request is running or queued, - no subscriber is attached. - The stored task is not modified. A follow-up recreates the `ActiveTask` from the store. - `0` releases as soon as the task is idle. ### Registry fix Removing a finished `ActiveTask` from the registry runs asynchronously. A follow-up that arrived in between was rejected with `Task … is already completed.` `get_or_create` now replaces a finished entry, and the scheduled removal only deletes the instance it was scheduled for. Side effect without a timeout: a follow-up to a task that just ended now gets `is in terminal state: …`, the same error as outside that window. ### Tests Seven new tests in `test_default_request_handler_v2.py`: - no timeout by default, - release for input-required and auth-required, with no leftover asyncio tasks and the stored state unchanged, - resume from the store after a release, - reset by a follow-up, - reset by a subscriber, - no release while the executor is still running, - a follow-up to a finished but still registered `ActiveTask` (this one fails on `main`). ### Checks - `./scripts/lint.sh`: passed (the 3 `ty` warnings already exist on `main`). - `uv run pytest`: 2285 passed. The new tests passed 10/10 repeated runs and also pass on Python 3.14. - Not tested: cluster mode (`event_stream`). Part of #1296 Release-As: 1.3.0 --------- Co-authored-by: Mateusz Iwaniuk <45974414+Iwaniukooo11@users.noreply.github.com>
1 parent 167bc16 commit b22a68d

4 files changed

Lines changed: 379 additions & 6 deletions

File tree

‎src/a2a/server/agent_execution/active_task.py‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ async def _process_event_inner(self, event: Event) -> None:
246246
'Consumer[%s]: Request completed', self.active_task._task_id
247247
)
248248
self.active_task._request_lock.release()
249+
self.active_task.schedule_idle_release()
249250
elif isinstance(event, _RequestStarted):
250251
logger.debug(
251252
'Consumer[%s]: Request started', self.active_task._task_id
@@ -476,6 +477,7 @@ def __init__( # noqa: PLR0913
476477
push_sender: PushNotificationSender | None = None,
477478
on_cleanup: Callable[[ActiveTask], None] | None = None,
478479
event_stream: TaskEventStream | None = None,
480+
interrupted_task_timeout: float | None = None,
479481
) -> None:
480482
"""Initializes the ActiveTask.
481483
@@ -489,6 +491,8 @@ def __init__( # noqa: PLR0913
489491
the task from the ActiveTaskRegistry.
490492
event_stream: Optional cross-replica stream; applied events are
491493
published to it so other replicas can observe them.
494+
interrupted_task_timeout: Optional seconds after which an idle
495+
interrupted task is finished; ``None`` disables it.
492496
"""
493497
# --- Core Dependencies ---
494498
self._agent_executor = agent_executor
@@ -501,6 +505,9 @@ def __init__( # noqa: PLR0913
501505
self._push_sender = push_sender
502506
self._on_cleanup = on_cleanup
503507
self._event_stream = event_stream
508+
self._interrupted_task_timeout = interrupted_task_timeout
509+
# Pending timer that releases a task parked in an interrupted state.
510+
self._idle_release_task: asyncio.Task[None] | None = None
504511

505512
# --- Synchronization Primitives ---
506513
# `_lock` protects structural lifecycle changes: start(), subscribe() counting,
@@ -537,6 +544,11 @@ def task_id(self) -> str:
537544
"""The ID of the task."""
538545
return self._task_id
539546

547+
@property
548+
def is_finished(self) -> bool:
549+
"""Whether the task's active lifecycle has ended."""
550+
return self._is_finished.is_set()
551+
540552
async def enqueue_request(
541553
self, request_context: RequestContext
542554
) -> uuid.UUID:
@@ -717,6 +729,7 @@ async def _run_consumer(self) -> None:
717729
await EventConsumer(self).run()
718730
finally:
719731
self._is_finished.set()
732+
self.cancel_idle_release()
720733
self._request_queue.shutdown(immediate=True)
721734
await self._event_queue_agent.close(immediate=True)
722735

@@ -737,7 +750,7 @@ async def _run_consumer(self) -> None:
737750
logger.debug('Consumer[%s]: Finishing', self._task_id)
738751
await self._maybe_cleanup()
739752

740-
async def subscribe(
753+
async def subscribe( # noqa: PLR0915
741754
self,
742755
*,
743756
request: RequestContext | None = None,
@@ -753,6 +766,7 @@ async def subscribe(
753766
"""
754767
logger.debug('Subscribe[%s]: New subscriber', self._task_id)
755768

769+
self.cancel_idle_release()
756770
async with self._lock:
757771
if self._is_finished.is_set():
758772
raise UnsupportedOperationError(
@@ -843,6 +857,7 @@ async def subscribe(
843857
self._reference_count -= 1
844858
# Evaluate if this was the last subscriber on a finished task.
845859
await self._maybe_cleanup()
860+
self.schedule_idle_release()
846861

847862
async def cancel(self, call_context: ServerCallContext) -> Task:
848863
"""Cancels the running active task.
@@ -968,6 +983,7 @@ async def aclose(self) -> None:
968983
# tasks, because their teardown re-acquires it.
969984
async with self._lock:
970985
self._is_finished.set()
986+
self.cancel_idle_release()
971987
background_tasks = [
972988
task
973989
for task in (self._producer_task, self._consumer_task)
@@ -984,6 +1000,54 @@ async def aclose(self) -> None:
9841000
if isinstance(result, Exception):
9851001
logger.error('Error during aclose', exc_info=result)
9861002

1003+
def schedule_idle_release(self) -> None:
1004+
"""(Re)starts the timer that releases a parked interrupted task."""
1005+
if self._interrupted_task_timeout is None or self._is_finished.is_set():
1006+
return
1007+
self.cancel_idle_release()
1008+
self._idle_release_task = asyncio.create_task(
1009+
self._release_when_idle(self._interrupted_task_timeout),
1010+
name=f'idle-release:{self._task_id}',
1011+
)
1012+
1013+
def cancel_idle_release(self) -> None:
1014+
"""Stops the timer that releases a parked interrupted task, if any."""
1015+
if self._idle_release_task is not None:
1016+
self._idle_release_task.cancel()
1017+
self._idle_release_task = None
1018+
1019+
async def _release_when_idle(self, timeout: float) -> None:
1020+
"""Finishes the task if it is still parked after `timeout` seconds."""
1021+
try:
1022+
await asyncio.sleep(timeout)
1023+
# Read before `_lock` so the checks and release below cannot suspend.
1024+
task = await self._task_manager.get_task()
1025+
except asyncio.CancelledError:
1026+
raise
1027+
except Exception:
1028+
logger.exception(
1029+
'ActiveTask[%s]: Idle release check failed', self._task_id
1030+
)
1031+
return
1032+
async with self._lock:
1033+
if (
1034+
task is None
1035+
or task.status.state not in INTERRUPTED_TASK_STATES
1036+
or self._is_finished.is_set()
1037+
# The consumer holds one reference; more means subscribers.
1038+
or self._reference_count != 1
1039+
or self._request_lock.locked()
1040+
or not self._request_queue.empty()
1041+
):
1042+
return
1043+
logger.debug(
1044+
'ActiveTask[%s]: Releasing task parked in %s',
1045+
self._task_id,
1046+
TaskState.Name(task.status.state),
1047+
)
1048+
self._is_finished.set()
1049+
self._request_queue.shutdown(immediate=True)
1050+
9871051
async def _maybe_cleanup(self) -> None:
9881052
"""Triggers cleanup if task is finished and has no subscribers.
9891053

‎src/a2a/server/agent_execution/active_task_registry.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ def __init__(
3434
task_store: TaskStore | VersionedTaskStore,
3535
push_sender: PushNotificationSender | None = None,
3636
event_stream: TaskEventStream | None = None,
37+
interrupted_task_timeout: float | None = None,
3738
):
3839
self._agent_executor = agent_executor
3940
self._task_store = task_store
4041
self._push_sender = push_sender
42+
self._interrupted_task_timeout = interrupted_task_timeout
4143
self._event_stream = event_stream
4244
self._active_tasks: dict[str, ActiveTask] = {}
4345
self._lock = threading.RLock()
@@ -57,6 +59,12 @@ async def get_or_create(
5759
if self._closed:
5860
raise RuntimeError('ActiveTaskRegistry is closed')
5961
existing = self._active_tasks.get(task_id)
62+
if existing is not None and existing.is_finished:
63+
# Its removal runs asynchronously, so replace it instead.
64+
existing = None
65+
if existing is not None:
66+
# A request is about to use it, so it must not be released.
67+
existing.cancel_idle_release()
6068
if existing is None:
6169
task_manager = TaskManager(
6270
task_id=task_id,
@@ -73,6 +81,7 @@ async def get_or_create(
7381
push_sender=self._push_sender,
7482
on_cleanup=self._on_active_task_cleanup,
7583
event_stream=self._event_stream,
84+
interrupted_task_timeout=self._interrupted_task_timeout,
7685
)
7786
self._active_tasks[task_id] = active_task
7887

@@ -99,6 +108,7 @@ async def get_or_create(
99108
if isinstance(existing_task, StoredTask):
100109
existing_task = existing_task.task
101110
if not existing_task:
111+
existing.schedule_idle_release()
102112
raise TaskNotFoundError
103113
return existing
104114

@@ -111,14 +121,19 @@ async def get_or_create(
111121
def _on_active_task_cleanup(self, active_task: ActiveTask) -> None:
112122
"""Called by ActiveTask when it's finished and has no subscribers."""
113123
logger.debug('Active task %s cleanup scheduled', active_task.task_id)
114-
task = asyncio.create_task(self._remove_task(active_task.task_id))
124+
task = asyncio.create_task(self._remove_task(active_task))
115125
self._cleanup_tasks.add(task)
116126
task.add_done_callback(self._cleanup_tasks.discard)
117127

118-
async def _remove_task(self, task_id: str) -> None:
128+
async def _remove_task(self, active_task: ActiveTask) -> None:
129+
task_id = active_task.task_id
119130
with self._lock:
120-
self._active_tasks.pop(task_id, None)
121-
logger.debug('Removed active task for %s from registry', task_id)
131+
# Only remove this instance; it may already have been replaced.
132+
if self._active_tasks.get(task_id) is active_task:
133+
del self._active_tasks[task_id]
134+
logger.debug(
135+
'Removed active task for %s from registry', task_id
136+
)
122137

123138
async def get(self, task_id: str) -> ActiveTask | None:
124139
"""Retrieves an existing task."""

‎src/a2a/server/request_handlers/default_request_handler_v2.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ class DefaultRequestHandlerV2(RequestHandler):
9696
with `DefaultRequestHandler` but is not used: v2 delegates event streaming
9797
to an in-memory `ActiveTaskRegistry`. Passing a non-``None`` value emits a
9898
`DeprecationWarning` and logs a warning.
99+
100+
``interrupted_task_timeout`` releases the `ActiveTask` of a task idle in
101+
``input-required`` or ``auth-required`` after that many seconds; a
102+
follow-up recreates it from the store. ``None`` (default) never releases;
103+
any other value must be greater than 0.
99104
"""
100105

101106
_background_tasks: set[asyncio.Task]
@@ -118,7 +123,13 @@ def __init__( # noqa: PLR0913
118123
push_url_validator: Callable[[str], Awaitable[bool]] | None = None,
119124
validate_input_modes: bool = False,
120125
event_stream: TaskEventStream | None = None,
126+
interrupted_task_timeout: float | None = None,
121127
) -> None:
128+
if (
129+
interrupted_task_timeout is not None
130+
and interrupted_task_timeout <= 0
131+
):
132+
raise ValueError('interrupted_task_timeout must be greater than 0.')
122133
if queue_manager is not None:
123134
message = (
124135
'A queue_manager was passed to DefaultRequestHandlerV2, but it '
@@ -172,6 +183,7 @@ def __init__( # noqa: PLR0913
172183
task_store=self._versioned_store,
173184
push_sender=self._push_sender,
174185
event_stream=self._event_stream,
186+
interrupted_task_timeout=interrupted_task_timeout,
175187
)
176188
self._background_tasks = set()
177189

@@ -260,7 +272,11 @@ async def _cancel_local(
260272
active_task = await self._active_task_registry.get_or_create(
261273
task_id, call_context=context, create_task_if_missing=False
262274
)
263-
result = await active_task.cancel(context)
275+
try:
276+
result = await active_task.cancel(context)
277+
finally:
278+
# get_or_create stopped the timer; a failed cancel leaves the task parked.
279+
active_task.schedule_idle_release()
264280
except UnsupportedOperationError as e:
265281
raise TaskNotCancelableError from e
266282

0 commit comments

Comments
 (0)