Skip to content

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

Closed

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.

Activity

  1. technicalpickles commented on Aug 8, 2026

    @technicalpickles
    ContributorAuthor

    Repro is up in #1171: both paths as xfail(strict=True) scenarios, no fix attached. Runs in about 0.2s, no new dependencies.

  2. self-assigned this
    on Aug 10, 2026
  3. impartshadow commented on Aug 10, 2026

    @impartshadow

    This is a silent-success state-transition failure: cancellation returns normally without establishing either a terminal task state or an actionable error.

    My root-cause hypothesis is that V2 lost both halves of V1’s cancellation contract. In the live path, ActiveTask.cancel cancels _producer_task before awaiting the executor, even though “the producer is the task running execute(), and it's the only component that writes the task's terminal state.” An executor whose cancel() only performs cleanup therefore cannot rely on the now-dead producer to publish CANCELED. In the parked path, input-required is nonterminal but has no running producer, so ActiveTask.cancel takes the no-op branch; then V2 returns that unchanged task because it omitted V1’s postcondition check. The shared defect is not merely ordering: successful return from the cancellation boundary no longer guarantees a terminal state.

    I would catch this at DefaultRequestHandlerV2.on_cancel_task’s return boundary. Run one scenario while execute() is active and another after it has produced INPUT_REQUIRED, using an executor with cancel(): pass. Assert that every normal return has result.status.state == CANCELED; otherwise assert TaskNotCancelableError. Then immediately call get_task and assert the persisted state matches that result and is terminal. That makes “returned success but persisted no transition” impossible regardless of which internal branch ran.

    I’m uncertain whether the framework or executor is intended to author CANCELED; confirmation would be an explicit spec statement or existing conformance test assigning that responsibility, while a contrary contract requiring executors to enqueue the event would refute the framework-owned fallback portion but not the missing V2 post-check.

    Advisory SFA-2026-C7F2E65D71 · Full redacted write-up: https://impartshadow.github.io/echo-site/failure-cases/cancel-reports-success-but-writes-no-terminal-state-c7f2e65d71.html — I triage one of these in public every day.
    If this diagnosis is useful, react 👍 to this comment; if it is wrong, react 👎 — I use the verdict to choose what I investigate next.

  4. added a commit that references this issue on Aug 11, 2026
  5. technicalpickles commented on Aug 12, 2026

    @technicalpickles
    ContributorAuthor

    Seconding astrogilda's check above: #1172 fixes both cancel paths this issue tracks, not just the one #1171 pins. Would be good to see #1172 land and close this out.

  6. rohityan commented on Aug 12, 2026

    @rohityan

    Hi @technicalpickles , Thank you for this detailed breakdown and for opening PR #1171 with the reproduction test scenarios.
    Your analysis of premature producer teardown during mid-run cancel and silent no-ops on parked INPUT_REQUIRED tasks was correct. I have verified PR #1172 fixes the issue. However till that PR gets reviewed you could use these workarounds

    1. If your AgentExecutor defines an empty cancel() method (async def cancel(self, ...): pass), update it to explicitly emit a terminal CANCELED event using TaskUpdater or TaskStatusUpdateEvent:
    2. If you have tasks that might be cancelled while parked in INPUT_REQUIRED (or if you don't control the agent executor code), subclass DefaultRequestHandlerV2 to force-write TASK_STATE_CANCELED to task_store whenever on_cancel_task returns a non-terminal state:
  7. added theissue type on Aug 12, 2026
  8. added a commit that references this issue on Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

component: serverIssues related to frameworks for agent execution, HTTP/event handling, database persistence logic.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions