From 8a0d48eadae8bb20b4cf1e77a27e751ce0afc43e Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Wed, 15 Jul 2026 18:13:43 +0800 Subject: [PATCH] feat(cloud): surface cancellation over the internal Host<->Cloud protocol - LeaseRenewalResponse gains cancel_requested (populated from the repository's renew_lease result) - TerminalResultRequest.status widened to accept "cancelled" - Add internal API tests for a renewal surfacing cancel_requested=True and a cancelled terminal report being accepted/idempotent --- openspec/changes/task-cancellation/tasks.md | 10 ++-- .../cloud-platform/cloud/internal_api/api.py | 7 +-- .../cloud/internal_api/models.py | 3 +- tests/test_host_agent_internal_api.py | 53 +++++++++++++++++++ 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/openspec/changes/task-cancellation/tasks.md b/openspec/changes/task-cancellation/tasks.md index e1dab15..96c3c1a 100644 --- a/openspec/changes/task-cancellation/tasks.md +++ b/openspec/changes/task-cancellation/tasks.md @@ -21,11 +21,11 @@ ## 3. Internal Host↔Cloud protocol -- [ ] 3.1 Add `cancel_requested: bool = False` field to `LeaseRenewalResponse` (`internal_api/models.py`). -- [ ] 3.2 Widen `TerminalResultRequest.status` (`internal_api/models.py`) to `Literal["done", "failed", "cancelled"]`. -- [ ] 3.3 Update `renew_assignment` route (`internal_api/api.py`) to populate `LeaseRenewalResponse.cancel_requested` from the repository's `renew_lease` result. -- [ ] 3.4 Update `report_result` route (`internal_api/api.py`) to accept and forward the `"cancelled"` status to `record_task_result`. -- [ ] 3.5 Add/extend internal API tests covering a renewal response surfacing `cancel_requested=True` and a `"cancelled"` terminal report being accepted and idempotent on repeat. +- [x] 3.1 Add `cancel_requested: bool = False` field to `LeaseRenewalResponse` (`internal_api/models.py`). +- [x] 3.2 Widen `TerminalResultRequest.status` (`internal_api/models.py`) to `Literal["done", "failed", "cancelled"]`. +- [x] 3.3 Update `renew_assignment` route (`internal_api/api.py`) to populate `LeaseRenewalResponse.cancel_requested` from the repository's `renew_lease` result. +- [x] 3.4 Update `report_result` route (`internal_api/api.py`) to accept and forward the `"cancelled"` status to `record_task_result`. +- [x] 3.5 Add/extend internal API tests covering a renewal response surfacing `cancel_requested=True` and a `"cancelled"` terminal report being accepted and idempotent on repeat. ## 4. Host Agent collaborative stop diff --git a/packages/cloud-platform/cloud/internal_api/api.py b/packages/cloud-platform/cloud/internal_api/api.py index 190d4e3..1bbaf74 100644 --- a/packages/cloud-platform/cloud/internal_api/api.py +++ b/packages/cloud-platform/cloud/internal_api/api.py @@ -316,7 +316,7 @@ def create_internal_router( summary=payload.progress.summary[:500], updated_at=now, ) - renewal_status = pool.store.renew_lease( + renewal = pool.store.renew_lease( task_id=task_id, attempt=payload.attempt, lease_id=payload.lease_id, @@ -325,16 +325,17 @@ def create_internal_router( now=now, progress=progress_snapshot, ) - if renewal_status == "not_found": + if renewal.status == "not_found": raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="assignment not found", ) - if renewal_status != "renewed": + if renewal.status != "renewed": return _stale_lease_conflict("assignment lease is stale or expired") return LeaseRenewalResponse( status="renewed", lease_expires_at=lease_expires_at, + cancel_requested=renewal.cancel_requested, ) @router.post( diff --git a/packages/cloud-platform/cloud/internal_api/models.py b/packages/cloud-platform/cloud/internal_api/models.py index ee08683..8104ed2 100644 --- a/packages/cloud-platform/cloud/internal_api/models.py +++ b/packages/cloud-platform/cloud/internal_api/models.py @@ -95,6 +95,7 @@ class LeaseRenewalRequest(BaseModel): class LeaseRenewalResponse(BaseModel): status: Literal["renewed"] lease_expires_at: datetime + cancel_requested: bool = False class TerminalResultRequest(BaseModel): @@ -102,7 +103,7 @@ class TerminalResultRequest(BaseModel): task_id: str = Field(min_length=1) attempt: int = Field(ge=1) lease_id: str = Field(min_length=1) - status: Literal["done", "failed"] + status: Literal["done", "failed", "cancelled"] failure_reason: str | None = None result: dict[str, Any] | None = None diff --git a/tests/test_host_agent_internal_api.py b/tests/test_host_agent_internal_api.py index 86d6a42..ae71dd3 100644 --- a/tests/test_host_agent_internal_api.py +++ b/tests/test_host_agent_internal_api.py @@ -473,10 +473,32 @@ def test_lease_renewal_extends_active_assignment(tmp_path) -> None: assert response.status_code == 200 assert response.json()["status"] == "renewed" + assert response.json()["cancel_requested"] is False renewed_expiry = datetime.fromisoformat(response.json()["lease_expires_at"]) assert renewed_expiry > original_time + timedelta(seconds=30) +def test_lease_renewal_surfaces_pending_cancellation(tmp_path) -> None: + client, pool = _build_client(tmp_path) + _seed_active_assignment(pool) + pool.store.request_task_cancellation("active-task", requested_at=datetime.now(UTC)) + + response = client.post( + "/internal/v1/hosts/host-a/assignments/active-task/renew", + headers={"Authorization": "Bearer token-a"}, + json={ + "host_id": "host-a", + "task_id": "active-task", + "attempt": 1, + "lease_id": "active-lease", + }, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "renewed" + assert response.json()["cancel_requested"] is True + + def test_stale_renewal_returns_typed_conflict(tmp_path) -> None: client, pool = _build_client(tmp_path) _seed_active_assignment(pool) @@ -526,6 +548,37 @@ def test_terminal_result_is_idempotent_through_internal_api(tmp_path) -> None: assert pool.store.get_task("active-task").status == "done" # type: ignore[union-attr] +def test_cancelled_terminal_result_is_accepted_and_idempotent(tmp_path) -> None: + client, pool = _build_client(tmp_path) + _seed_active_assignment(pool) + pool.store.request_task_cancellation("active-task", requested_at=datetime.now(UTC)) + payload = { + "host_id": "host-a", + "task_id": "active-task", + "attempt": 1, + "lease_id": "active-lease", + "status": "cancelled", + "failure_reason": "cancellation requested by control plane", + } + + first = client.post( + "/internal/v1/hosts/host-a/assignments/active-task/result", + headers={"Authorization": "Bearer token-a"}, + json=payload, + ) + repeated = client.post( + "/internal/v1/hosts/host-a/assignments/active-task/result", + headers={"Authorization": "Bearer token-a"}, + json=payload, + ) + + assert first.status_code == 200 + assert first.json()["status"] == "recorded" + assert repeated.status_code == 200 + assert repeated.json()["status"] == "already_recorded" + assert pool.store.get_task("active-task").status == "cancelled" # type: ignore[union-attr] + + def test_conflicting_repeated_result_returns_stale_lease_conflict(tmp_path) -> None: client, pool = _build_client(tmp_path) _seed_active_assignment(pool)