Add public SDK cancel endpoint and CloudClient method

- POST /v1/tasks/{task_id}/cancel: tasks:submit scoped, 200 for
  immediate/idempotent cancellation, 202 for newly recorded pending
  cancellation, 404 for unknown task, 409 for terminal task.
- TaskCancellationResponse{task_id, status} model.
- Widen list_tasks status_filter Literal to include "cancelled".
- CloudClient.cancel_task(task_id).
- SDK-level tests covering queued/assigned/idempotent/404/409/scope
  cases for both the router and CloudClient.

Task 5/9 of task-cancellation change.
This commit is contained in:
2026-07-15 18:39:00 +08:00
parent d3024b4810
commit 6776ac2f2d
6 changed files with 192 additions and 7 deletions
+36 -2
View File
@@ -11,6 +11,7 @@ authentication can be added later without changing route signatures.
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Callable, Literal
from cloud.auth import (
@@ -31,6 +32,7 @@ from cloud.sdk.models import (
PluginRegistrationRequest,
PluginResponse,
TaskAttemptResponse,
TaskCancellationResponse,
TaskListItem,
TaskListResponse,
TaskPlannerDecisionItem,
@@ -39,7 +41,7 @@ from cloud.sdk.models import (
TaskSubmissionRequest,
TaskSubmissionResponse,
)
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
if TYPE_CHECKING:
from cloud.plugins import PluginRegistry
@@ -156,7 +158,9 @@ def create_cloud_router(
@router.get("/tasks", response_model=TaskListResponse)
def list_tasks(
request: Request,
status_filter: Literal["queued", "assigned", "dispatched", "done", "failed"]
status_filter: Literal[
"queued", "assigned", "dispatched", "done", "failed", "cancelled"
]
| None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
@@ -268,6 +272,36 @@ def create_cloud_router(
)
return TaskPlannerDecisionListResponse(items=items)
@router.post(
"/tasks/{task_id}/cancel",
response_model=TaskCancellationResponse,
responses={
status.HTTP_202_ACCEPTED: {"model": TaskCancellationResponse},
},
)
def cancel_task(
task_id: str, request: Request, response: Response
) -> TaskCancellationResponse:
_authorize(request, TASKS_SUBMIT_SCOPE)
result = scheduler.store.request_task_cancellation(
task_id, requested_at=datetime.now(UTC)
)
if result == "not_found":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task {task_id!r} not found",
)
if result == "already_terminal":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"task {task_id!r} has already reached a terminal state",
)
task = scheduler.store.get_task(task_id)
assert task is not None
if result == "requested" and task.status != "cancelled":
response.status_code = status.HTTP_202_ACCEPTED
return TaskCancellationResponse(task_id=task_id, status=task.status)
@router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request, POOL_READ_SCOPE)