[Bug]: CancelTask racing the task's own completion overwrites COMPLETED with FAILED
維護者通常 2 天內回覆
評估
這個 Issue 還沒有評估資料。
描述
What happened?
A task can finish on its own while a CancelTask request for it is in flight. When it does, DefaultRequestHandlerV2 (via ActiveTask.cancel) can overwrite the task's terminal COMPLETED state with FAILED. Reproduced on 1.2.1; the same code is on main (43d1eff) and in 1.3.0.
The window is real. ActiveTask.cancel only checks _is_finished, which the consumer sets once it has processed the terminal event. A complete() that the executor has already enqueued, but the consumer has not yet handled, passes that check. So AgentExecutor.cancel() runs against a task that has already finished.
A completed task is overwritten with FAILED
If executor.cancel() raises, ActiveTask.cancel calls _mark_task_as_failed. That method enqueues TASK_STATE_FAILED without checking whether the task is already terminal. Its sibling _write_failed_status does check.
An executor that shares one TaskUpdater between execute and cancel hits this naturally: when cancel() loses to complete(), TaskUpdater.update_status raises RuntimeError("... already in a terminal state"). The connected client has already received COMPLETED and the result over the stream, yet GetTask afterwards reports FAILED.
The spec defines TASK_STATE_COMPLETED as "a terminal state" (§4.1.3 TaskState; a2a.proto: "Indicates that a task has finished successfully. This is a terminal state."). A streaming client is told it is final, because the stream "MUST close when the task reaches a terminal state" (§3.1.2). After that, the stored state should not change, and in particular should not contradict what was streamed.
Expected: a terminal state is never replaced; a failing cancel() on an already-terminal task leaves it COMPLETED.
Related question: what should CancelTask answer in this race?
Even when cancel() does not raise, ActiveTask.cancel re-reads the task and returns it in whatever terminal state it reached. So CancelTask responds successfully with a TASK_STATE_COMPLETED task. The spec doesn't settle this case clearly. §3.1.5 Cancel Task says:
- "success is not guaranteed (e.g., the task might have already completed or failed…)" and "returns its updated state", which supports the current behaviour;
- Outputs: "Updated
Taskwith cancellation status", and the errorTaskNotCancelableError: "The task is not in a cancelable state (e.g., already completed, failed, or canceled)", which supports an error.
The SDK itself answers both ways. The cross-replica path, _cancel_remote, raises TaskNotCancelableError when the reloaded task is not CANCELED; the local path returns the task. Is that difference intended? If not, it would help to pick one.
Repro (self-contained, a2a-sdk 1.2.1, no transport). The executor's cancel() lets the in-flight work finish and complete the task, which deterministically puts the cancel in the window above:
import asyncio
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.context import ServerCallContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandlerV2
from a2a.server.tasks import InMemoryTaskStore, TaskUpdater
from a2a.types import (
AgentCapabilities,
AgentCard,
CancelTaskRequest,
GetTaskRequest,
Message,
Part,
Role,
SendMessageRequest,
Task,
TaskState,
TaskStatus,
)
class FinishesDuringCancel(AgentExecutor):
"""The task's own work completes it while cancel() is in progress."""
def __init__(self, cancel_raises: bool) -> None:
self.cancel_raises = cancel_raises
self.started = asyncio.Event()
self.release = asyncio.Event()
self.completed = asyncio.Event()
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
task_id, context_id = context.task_id, context.context_id
await event_queue.enqueue_event(
Task(id=task_id, context_id=context_id, status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED))
)
updater = TaskUpdater(event_queue, task_id, context_id)
await updater.start_work()
self.started.set()
await self.release.wait()
await updater.complete()
self.completed.set()
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
self.release.set()
await self.completed.wait()
if self.cancel_raises:
# What a shared TaskUpdater raises when cancel() loses the race to complete().
raise RuntimeError("Task is already in a terminal state.")
async def drain(stream) -> None:
async for _ in stream:
pass
async def run(cancel_raises: bool) -> None:
executor = FinishesDuringCancel(cancel_raises)
handler = DefaultRequestHandlerV2(
agent_executor=executor,
task_store=InMemoryTaskStore(),
agent_card=AgentCard(name="repro", capabilities=AgentCapabilities(streaming=True)),
)
ctx = ServerCallContext()
message = Message(role=Role.ROLE_USER, parts=[Part(text="go")], message_id="m1")
stream = handler.on_message_send_stream(SendMessageRequest(message=message), ctx)
first = await anext(stream)
task_id = first.id if isinstance(first, Task) else first.task_id
# Keep reading the stream, as a connected client does.
draining = asyncio.create_task(drain(stream))
await executor.started.wait()
try:
result = await handler.on_cancel_task(CancelTaskRequest(id=task_id), ctx)
outcome = f"returned task in {TaskState.Name(result.status.state)}"
except Exception as e:
outcome = f"raised {type(e).__name__}: {e}"
await draining
stored = await handler.on_get_task(GetTaskRequest(id=task_id), ctx)
print(f"cancel_raises={cancel_raises}: CancelTask {outcome}; stored state {TaskState.Name(stored.status.state)}")
asyncio.run(run(cancel_raises=False))
asyncio.run(run(cancel_raises=True))
Output (3 of 3 runs):
cancel_raises=False: CancelTask returned task in TASK_STATE_COMPLETED; stored state TASK_STATE_COMPLETED
cancel_raises=True: CancelTask raised RuntimeError: Task is already in a terminal state.; stored state TASK_STATE_FAILED
The second line is the bug: the stored state should stay TASK_STATE_COMPLETED. The first line shows the behaviour the related question is about.
Possible fix: in _mark_task_as_failed (or before calling it from cancel), skip the FAILED write when the task is already terminal, as _write_failed_status does.
We hit this in AG2's A2A server, whose executor shares one TaskUpdater between execute and cancel.
Relevant log output
Cancel[<task id>]: Agent cancel failed
Traceback (most recent call last):
File ".../a2a/server/agent_execution/active_task.py", line 834, in cancel
await self._agent_executor.cancel(
File "repro.py", line 54, in cancel
raise RuntimeError("Task is already in a terminal state.")
RuntimeError: Task is already in a terminal state.
Code of Conduct
- I agree to follow this project's Code of Conduct
- 主要語言
- Python
- 星號
- 2.2k
- 分支
- 528
- 平均合併
- 3 天 11 小時
- 30 天內合併 PR
- 56
環境準備
- 沒有 Dockerfile 或 Docker Compose 檔案
- 有 Pull Request 範本
- 閱讀貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
a2aproject/a2a-python 的其他 Issue
-
[Bug]: `A2A-Version` validation ignores minor versions and the query parameter可能已有人在做 @rohityan 於 66 天前認領。 未關閉component: server status:awaiting response
難度 2/5 1-3 小時 新手友好度 75/100
a2aproject/a2a-python#1204 · 3 則留言 · 已指派 1 人 ·
維護者通常 2 天內回覆
-
[Bug]: REST task/request id sanitization可能已有人在做 @Linux2010 於 124 天前認領。 未關閉maintainers-only
難度 2/5 1-3 小時 新手友好度 65/100
a2aproject/a2a-python#805 · 1 則留言 ·
維護者通常 2 天內回覆
-
[Feat] Major list可能已有人在做 @rohityan 於 2 天前認領。 未關閉maintainers-only
a2aproject/a2a-python#1398 · 已指派 3 人 ·
維護者通常 2 天內回覆
-
v0.3 pushNotificationConfig/get by task id fails with Validation failed可能已有人在做 @ConnorMoss02 於 3 天前認領。 未關閉component: server status:awaiting response
a2aproject/a2a-python#1379 · 1 則留言 · 已指派 1 人 ·
維護者通常 2 天內回覆
-
[Bug]: REST tenant routes are mounted before `path_prefix`, so tenant requests from the SDK client get 404可能已有人在做 @Revinand 於 3 天前認領。 未關閉component: server status:awaiting response
a2aproject/a2a-python#1376 · 1 則留言 · 已指派 1 人 ·
維護者通常 2 天內回覆
查看 a2aproject/a2a-python 的全部 Issue
相似的 Issue
-
ai_p2 bug comp-datalake
難度 2/5 1-3 小時 新手友好度 85/100
ClickHouse/ClickHouse#128306 · 1 則留言 ·
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 85/100
維護者通常 1 天內回覆
-
難度 2/5 1-3 小時 新手友好度 75/100
維護者通常 1 天內回覆
-
[bug] openlit: LLM spans from openlit 1.37+ get no OpenInference attributes, and cache and reasoning token counts are dropped可能已有人在做 @sohom-cs 今天認領。 未關閉
難度 2/5 1-3 小時 新手友好度 74/100
Arize-ai/openinference#4199 · 1 個 reaction ·
維護者通常 2 天內回覆
-
難度 1/5 1 小時以內 新手友好度 82/100
MichaelYcJo/SpecSeal#1192 ·