Add Host Agent local console task cancellation (task-cancellation 7.1-7.3)

- New internal API route POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel,
  authenticated via the host's own bearer credential (authorize_host) with an
  ownership check, since host tokens carry no scopes and cannot reach the
  public SDK's tasks:submit-scoped cancel endpoint.
- HostAgentClient.cancel_task() calls the new internal route directly.
- create_console_app() gains a cancel_task callable with automatic default
  wiring from host_client, so production app.py needs no changes.
- Local console: POST /tasks/{task_id}/cancel route resolves the local
  execution id to its Cloud source_task_id before cancelling, and the task
  detail page/template show a Cancel button plus notice/error banners.
- Tests across all three layers: internal API route, Jinja2 template
  rendering, and FastAPI console route behavior.
This commit is contained in:
2026-07-15 19:13:40 +08:00
parent 4d04d7ac83
commit 18f053e64b
10 changed files with 518 additions and 5 deletions
@@ -10,7 +10,7 @@ from time import monotonic
from typing import TYPE_CHECKING
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, status
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from cloud.auth import (
@@ -29,6 +29,7 @@ from cloud.internal_api.models import (
HostGovernancePolicyModel,
HostEnrollmentRequest,
HostEnrollmentResponse,
HostTaskCancellationResponse,
HostTaskSubmissionRequest,
HostTaskSubmissionResponse,
LeaseRenewalRequest,
@@ -250,6 +251,50 @@ def create_internal_router(
)
return HostTaskSubmissionResponse(task_id=task_id)
@router.post(
"/hosts/{host_id}/tasks/{task_id}/cancel",
response_model=HostTaskCancellationResponse,
responses={
status.HTTP_202_ACCEPTED: {"model": HostTaskCancellationResponse},
},
)
def cancel_host_task(
host_id: str,
task_id: str,
request: Request,
response: Response,
) -> HostTaskCancellationResponse:
authorize_host(request, host_id)
if scheduler is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="task cancellation is unavailable",
)
task = scheduler.store.get_task(task_id)
if task is None or task.constraints.target_host_id != host_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task {task_id!r} not found",
)
result = scheduler.store.request_task_cancellation(
task_id, requested_at=utc_now()
)
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 HostTaskCancellationResponse(task_id=task_id, status=task.status)
@router.post(
"/hosts/{host_id}/assignments/claim",
response_model=ClaimResponse,
@@ -122,6 +122,11 @@ class HostTaskSubmissionResponse(BaseModel):
task_id: str
class HostTaskCancellationResponse(BaseModel):
task_id: str
status: str
class StaleLeaseConflict(BaseModel):
code: Literal["stale_lease"] = "stale_lease"
detail: str