Skip to content

Cancel reports success but writes no terminal state (DefaultRequestHandlerV2) #1170

Description

@technicalpickles

Cancelling a task can leave it with no terminal state at all. Two paths get there, and the V1 handler guarded both.

What happens

Against DefaultRequestHandlerV2 (what DefaultRequestHandler aliases to), with an executor whose cancel() is empty:

mid-run cancel:  cancel returned WORKING         later get_task: WORKING (forever)
parked task:     cancel returned INPUT_REQUIRED  task unchanged

Neither call errors. In the first case the task never reaches a terminal state at all, so a client that cancelled has no way to know it can stop polling. In the second, cancel reports success and does nothing.

Path 1: mid-run, ordering in ActiveTask.cancel

active_task.py:733-737 cancels the producer first, then awaits the executor's cancel:

self._producer_task.cancel()
try:
    await self._agent_executor.cancel(request_context, self._event_queue_agent)

The producer is the task running execute(), and it's the only component that writes the task's terminal state. Killing it first leaves nothing to write one.

V1 does this the other way round: default_request_handler.py:213 awaits agent_executor.cancel(...), and line 224 cancels the producer after.

Path 2: parked task, missing guard in on_cancel_task

ActiveTask.cancel only acts if not self._is_finished.is_set() and self._producer_task (active_task.py:729), so it treats cancelable as "has a running producer" rather than "is not terminal". A task parked in input-required has already returned from execute(), so it takes the else branch, logs "not cancelling", and returns the task untouched.

V1 caught that on the way out (default_request_handler.py:233):

if result.status.state != TaskState.TASK_STATE_CANCELED:
    raise TaskNotCancelableError(...)

V2's on_cancel_task (default_request_handler_v2.py:161-181) returns result whatever it is. The terminal-state pre-check at default_request_handler.py:195 doesn't cover this, since input-required isn't terminal.

I'm not assuming an answer on whether a parked task should be cancelable. Either outcome works as long as it's visible: cancel it, or raise TaskNotCancelableError. Reporting success and changing nothing is the one result a caller can't act on.

Why the tests didn't catch it

test_scenario_cancel_working_task_empty_cancel covers the mid-run case and passes. It passes because its executor hand-enqueues the CANCELED event, and there's a comment sitting right next to it wondering about this exact thing:

async def cancel(self, context, event_queue):
    # TODO: this should be done automatically by the framework ?
    await event_queue.enqueue_event(
        TaskStatusUpdateEvent(..., status=TaskStatus(state=TaskState.TASK_STATE_CANCELED))
    )

Most executors in that same file (InputRequiredAgent, SlowAgent, DummyAgentExecutor) define cancel() as pass, which is also what a real executor doing cleanup-only teardown looks like. Swap the hand-written event for pass and the scenario breaks.

Repro

PR to follow, adding two xfail(strict=True) scenarios to tests/integration/test_scenarios.py. No new dependencies, runs in about 0.2s.

Fix shape

Path 1: await the executor's cancel before killing the producer, the way V1 does, so the component that owns terminal state gets a chance to write one. Worth considering whether the framework should also close the task out when the executor didn't, which is what that TODO is asking.

Path 2: restore V1's post-check in on_cancel_task, or widen ActiveTask.cancel's condition from "has a running producer" to "is not terminal".

Versions

a2a-sdk 1.1.2, and confirmed on main at cff6727. Both active_task.py and default_request_handler.py are unchanged from the v1.1.2 tag.

Found while building a deterministic playback rig against a2acode, where a cancel arriving at a specific moment is a scenario file rather than a flaky live run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions