Repository navigation
Cancel reports success but writes no terminal state (DefaultRequestHandlerV2) #1170
Description
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
technicalpickles commentedon Aug 8, 2026
Repro is up in #1171: both paths as
xfail(strict=True)scenarios, no fix attached. Runs in about 0.2s, no new dependencies.File the cancel pair as one issue, with the repro in a2a-python's own…
impartshadow commentedon Aug 10, 2026
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.cancelcancels_producer_taskbefore awaiting the executor, even though “the producer is the task runningexecute(), and it's the only component that writes the task's terminal state.” An executor whosecancel()only performs cleanup therefore cannot rely on the now-dead producer to publishCANCELED. In the parked path,input-requiredis nonterminal but has no running producer, soActiveTask.canceltakes 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 whileexecute()is active and another after it has producedINPUT_REQUIRED, using an executor withcancel(): pass. Assert that every normal return hasresult.status.state == CANCELED; otherwise assertTaskNotCancelableError. Then immediately callget_taskand 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.
docs(test): credit a2aproject#1171 as the origin of the folded-in can…
technicalpickles commentedon Aug 12, 2026
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.
rohityan commentedon Aug 12, 2026
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_REQUIREDtasks was correct. I have verified PR #1172 fixes the issue. However till that PR gets reviewed you could use these workaroundsAgentExecutordefines an emptycancel()method (async def cancel(self, ...): pass), update it to explicitly emit a terminalCANCELEDevent usingTaskUpdaterorTaskStatusUpdateEvent:INPUT_REQUIRED(or if you don't control the agent executor code), subclassDefaultRequestHandlerV2to force-writeTASK_STATE_CANCELEDtotask_storewheneveron_cancel_taskreturns a non-terminal state:fix: owner-scope SubscribeToTask and CancelTask for live tasks (a2apr…
fix: cancel writes a terminal state instead of reporting a false succ…
docs(test): credit a2aproject#1171 as the origin of the folded-in can…
refactor(server): drop redundant handler owner guards now enforced in…
fix: owner-scope cancel/subscribe and write terminal state on cancel (a…
chore(main): release 1.1.4 (#1195)