Skip to content

Commit b509852

Browse files
fix: publish terminal task state to subscriber streams (#1175)
Cancel and producer-failure wrote CANCELED/FAILED to the store after queues closed, so a live stream saw WORKING then hang-up. Persist a copy and notify observers before teardown; cancel also emits the status event so subscribers do not have to poll. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4e71245 commit b509852

2 files changed

Lines changed: 243 additions & 11 deletions

File tree

src/a2a/server/agent_execution/active_task.py

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from __future__ import annotations
3737

3838
import asyncio
39+
import inspect
3940
import logging
4041
import uuid
4142

@@ -560,17 +561,26 @@ async def _run_producer(self) -> None:
560561
'Producer[%s]: Execution failed',
561562
self._task_id,
562563
)
563-
# Persist the failure directly instead of relying on the closing
564-
# event queue to carry a final status update.
564+
# Persist FAILED (store + push) before finally closes the
565+
# queues (issue #1175). Do not emit a FAILED status event:
566+
# blocking on_message_send would treat that Task as success.
567+
# The producer exception is the stream signal.
565568
if request_context:
566-
task = await self._task_manager.ensure_task_id(
567-
self._task_id,
568-
request_context.context_id or '',
569-
)
570-
if task.status.state not in TERMINAL_TASK_STATES:
571-
task.status.state = TaskState.TASK_STATE_FAILED
572-
await self._task_manager.save_task_event(task)
573-
self._task_created.set()
569+
try:
570+
await self._task_manager.ensure_task_id(
571+
self._task_id,
572+
request_context.context_id or '',
573+
)
574+
await self._persist_and_publish_terminal(
575+
TaskState.TASK_STATE_FAILED,
576+
publish_to_stream=False,
577+
)
578+
self._task_created.set()
579+
except Exception:
580+
logger.exception(
581+
'Producer[%s]: Failed to persist FAILED state',
582+
self._task_id,
583+
)
574584
await self._event_queue_agent.enqueue_event(cast('Event', e))
575585

576586
finally:
@@ -730,7 +740,10 @@ async def cancel(self, call_context: ServerCallContext) -> Task:
730740
logger.debug(
731741
'Cancel[%s]: Cancelling producer task', self._task_id
732742
)
733-
self._producer_task.cancel()
743+
# Await executor.cancel before cancelling the producer so a
744+
# terminal write can still reach the open subscriber queue
745+
# (#1172 / #1175). Producer cancel stays in finally so a
746+
# BaseException from executor.cancel cannot leak the producer.
734747
try:
735748
await self._agent_executor.cancel(
736749
request_context, self._event_queue_agent
@@ -741,13 +754,42 @@ async def cancel(self, call_context: ServerCallContext) -> Task:
741754
)
742755
await self._mark_task_as_failed(e)
743756
raise
757+
finally:
758+
try:
759+
task = await self._task_manager.get_task()
760+
if (
761+
task is not None
762+
and task.status.state not in TERMINAL_TASK_STATES
763+
):
764+
# Cleanup-only executor.cancel() or a parked
765+
# input-required task leaves no terminal event.
766+
# Write CANCELED and publish it while queues
767+
# are still open so a live subscriber does not
768+
# have to poll.
769+
await self._persist_and_publish_terminal(
770+
TaskState.TASK_STATE_CANCELED
771+
)
772+
except Exception:
773+
logger.exception(
774+
'Cancel[%s]: Failed to persist CANCELED state',
775+
self._task_id,
776+
)
777+
self._producer_task.cancel()
744778
else:
745779
logger.debug(
746780
'Cancel[%s]: Task already finished [%s] or producer not started [%s], not cancelling',
747781
self._task_id,
748782
self._is_finished.is_set(),
749783
self._producer_task,
750784
)
785+
task = await self._task_manager.get_task()
786+
if (
787+
task is not None
788+
and task.status.state not in TERMINAL_TASK_STATES
789+
):
790+
await self._persist_and_publish_terminal(
791+
TaskState.TASK_STATE_CANCELED
792+
)
751793

752794
await self._is_finished.wait()
753795
task = await self._task_manager.get_task()
@@ -820,6 +862,54 @@ async def _maybe_cleanup(self) -> None:
820862
logger.debug('Cleanup[%s]: Triggering cleanup', self._task_id)
821863
self._on_cleanup(self)
822864

865+
async def _persist_and_publish_terminal(
866+
self, state: TaskState, *, publish_to_stream: bool = True
867+
) -> Task | None:
868+
"""Write a terminal state to the store and notify live observers.
869+
870+
Direct ``save_task_event`` after the subscriber queue is closed is
871+
invisible to ``SubscribeToTask`` / ``message/stream`` and to push
872+
(issue #1175). This helper persists a *copy* of the current task
873+
(the shared ``get_task()`` object must not mutate under a reader)
874+
and, when ``publish_to_stream`` is true, emits a
875+
``TaskStatusUpdateEvent`` to subscribers *before* teardown.
876+
877+
Producer-failure keeps ``publish_to_stream=False``: blocking
878+
``on_message_send`` treats a FAILED ``Task`` as a successful
879+
terminal result, so the crash must still surface as the
880+
producer exception on the stream. Store and push still get
881+
FAILED before the queues close.
882+
"""
883+
task = await self._task_manager.get_task()
884+
if task is None:
885+
return None
886+
887+
if task.status.state not in TERMINAL_TASK_STATES:
888+
updated = Task()
889+
updated.CopyFrom(task)
890+
updated.status.state = state
891+
await self._task_manager.save_task_event(updated)
892+
task = updated
893+
894+
event = TaskStatusUpdateEvent(
895+
task_id=task.id,
896+
context_id=task.context_id,
897+
status=TaskStatus(state=task.status.state),
898+
)
899+
if publish_to_stream:
900+
updated_task_copy = Task()
901+
updated_task_copy.CopyFrom(task)
902+
await self._event_queue_subscribers.enqueue_event(
903+
cast('Any', (event, updated_task_copy))
904+
)
905+
if self._push_sender and self._task_id:
906+
notification = self._push_sender.send_notification(
907+
self._task_id, event
908+
)
909+
if inspect.isawaitable(notification):
910+
await notification
911+
return task
912+
823913
async def _mark_task_as_failed(self, exception: Exception) -> Task | None:
824914
logger.debug('Marking task %s as failed: %s', self._task_id, exception)
825915
task = None

tests/server/agent_execution/test_active_task.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,148 @@ async def execute_mock(req, q):
129129
agent_executor.cancel.assert_called_once()
130130
stop_event.set()
131131

132+
@staticmethod
133+
def _wire_current_task(task_manager: Mock, task: Task) -> None:
134+
"""Make get_task / save_task_event share one current Task."""
135+
136+
async def get_task() -> Task:
137+
return task_manager._current_task
138+
139+
async def ensure_task_id(task_id: str, context_id: str) -> Task:
140+
return task_manager._current_task
141+
142+
async def save_task_event(event: Task) -> None:
143+
if isinstance(event, Task):
144+
task_manager._current_task = event
145+
146+
task_manager._current_task = task
147+
task_manager.get_task = AsyncMock(side_effect=get_task)
148+
task_manager.save_task_event = AsyncMock(side_effect=save_task_event)
149+
task_manager.ensure_task_id = AsyncMock(side_effect=ensure_task_id)
150+
151+
@pytest.mark.asyncio
152+
async def test_cancel_publishes_canceled_to_subscriber_stream(
153+
self,
154+
active_task: ActiveTask,
155+
agent_executor: Mock,
156+
request_context: Mock,
157+
task_manager: Mock,
158+
push_sender: Mock,
159+
) -> None:
160+
"""Issue #1175: cancel() must emit CANCELED on a live subscriber stream.
161+
162+
A cleanup-only executor.cancel() writes nothing. The helper persists a
163+
copy and enqueues TaskStatusUpdateEvent before the producer tears the
164+
subscriber queue down.
165+
"""
166+
stop_event = asyncio.Event()
167+
168+
async def execute_mock(req, q):
169+
await stop_event.wait()
170+
171+
shared = Task(
172+
id='test-task-id',
173+
context_id='test-context-id',
174+
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
175+
)
176+
self._wire_current_task(task_manager, shared)
177+
agent_executor.execute = AsyncMock(side_effect=execute_mock)
178+
agent_executor.cancel = AsyncMock()
179+
180+
await active_task.enqueue_request(request_context)
181+
await active_task.start(
182+
call_context=ServerCallContext(), create_task_if_missing=True
183+
)
184+
await asyncio.sleep(0.05)
185+
186+
events: list[object] = []
187+
188+
async def collect() -> None:
189+
try:
190+
async for event in active_task.subscribe():
191+
events.append(event)
192+
except Exception: # noqa: BLE001
193+
pass
194+
195+
collector = asyncio.create_task(collect())
196+
await asyncio.sleep(0.05)
197+
198+
result = await active_task.cancel(request_context)
199+
stop_event.set()
200+
await asyncio.wait_for(collector, timeout=2)
201+
202+
assert result.status.state == TaskState.TASK_STATE_CANCELED
203+
assert result is not shared
204+
assert shared.status.state == TaskState.TASK_STATE_WORKING
205+
status_events = [
206+
e
207+
for e in events
208+
if isinstance(e, TaskStatusUpdateEvent)
209+
and e.status.state == TaskState.TASK_STATE_CANCELED
210+
]
211+
assert status_events, f'subscriber saw {events!r}, expected CANCELED'
212+
push_sender.send_notification.assert_awaited()
213+
214+
@pytest.mark.asyncio
215+
async def test_producer_failure_persists_failed_and_notifies_push(
216+
self,
217+
active_task: ActiveTask,
218+
agent_executor: Mock,
219+
request_context: Mock,
220+
task_manager: Mock,
221+
push_sender: Mock,
222+
) -> None:
223+
"""Issue #1175: producer-failure persists FAILED and notifies push.
224+
225+
The crash still surfaces as ValueError on the stream: blocking
226+
on_message_send would treat a FAILED Task as a successful result.
227+
"""
228+
shared = Task(
229+
id='test-task-id',
230+
context_id='test-context-id',
231+
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
232+
)
233+
self._wire_current_task(task_manager, shared)
234+
request_context.context_id = 'test-context-id'
235+
crash = asyncio.Event()
236+
237+
async def execute_mock(req, q):
238+
await crash.wait()
239+
raise ValueError('Producer crashed')
240+
241+
agent_executor.execute = AsyncMock(side_effect=execute_mock)
242+
243+
await active_task.enqueue_request(request_context)
244+
await active_task.start(
245+
call_context=ServerCallContext(), create_task_if_missing=True
246+
)
247+
# Let the consumer flush _RequestStarted so this tap sees only the
248+
# terminal publish (same timing as the cancel-stream test).
249+
await asyncio.sleep(0.05)
250+
251+
collector_error: list[BaseException] = []
252+
253+
async def collect() -> None:
254+
try:
255+
async for _event in active_task.subscribe():
256+
pass
257+
except ValueError as exc:
258+
collector_error.append(exc)
259+
return
260+
261+
collector = asyncio.create_task(collect())
262+
await asyncio.sleep(0.05)
263+
crash.set()
264+
await asyncio.wait_for(collector, timeout=2)
265+
266+
assert collector_error, 'subscriber hung up instead of seeing the crash'
267+
assert (
268+
task_manager._current_task.status.state
269+
== TaskState.TASK_STATE_FAILED
270+
)
271+
assert shared.status.state == TaskState.TASK_STATE_WORKING
272+
push_sender.send_notification.assert_awaited()
273+
132274
@pytest.mark.asyncio
133275
async def test_active_task_interrupted_auth(
134276
self,

0 commit comments

Comments
 (0)