a2aproject / a2aproject/a2a-python
cancel() and producer-failure write a terminal state to the store but not to active subscriber streams
- Dominant language
- Python
- Stars
- 2.1k
- Forks
- 496
- Avg merge
- 4d 17h
- Merged PRs (30d)
- 12
Description
Follow-up from the review of #1172 (raised by @astrogilda).
### What happens
`ActiveTask.cancel()` now closes a task out to a terminal `CANCELED` state when
the executor's `cancel()` is cleanup-only or the task was parked in a
non-terminal state (e.g. `input-required`). That terminal state is written
directly to the task store via `save_task_event`, because by that point both
the agent and subscriber event queues are already closed.
The consequence is that the terminal state reaches the **store** but not an
**active subscriber stream**. A client that was streaming the task
(`SubscribeToTask` / `message/stream`) receives the initial `Task` in `WORKING`
and then the stream ends; it never sees a `CANCELED` event and has to re-read
the task to learn the outcome. The push path does not fire either, since
`_update_task_state` only notifies on `PushNotificationEvent`.
### Why it was left out of #1172
At the point of the direct store write, both queues are already closed, so a
store write is the only option left. The same pattern already exists for the
producer-failure path (`active_task.py`, the `FAILED` write in
`_run_producer`'s `except Exception` arm), under a comment that says so. #1172
kept scope to the authorization fix (#1159) and the "cancel leaves an actionable
terminal state" fix (#1170); this stream-visibility gap is orthogonal.
### Proposed follow-up
Give a streaming or push-subscribed client a way to observe the terminal state
without polling. Options to weigh:
- Emit a synthetic terminal `TaskStatusUpdateEvent` to `_event_queue_subscribers`
before the queues are torn down, so an active subscriber sees the transition.
- Route the terminal store write through the push path so `push_sender` fires.
Both the `cancel()` CANCELED write and the producer-failure `FAILED` write share
this gap and should get the same treatment.
### Repro
Subscribe to a live task whose executor's `cancel()` is cleanup-only, cancel it,
and collect the events the subscriber receives: the stream yields the initial
`WORKING` task and ends, while `task_store.get` afterwards returns `CANCELED`.
Contributor guide
Assessment
This issue has not been assessed yet.