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
This commit is contained in:
@@ -21,11 +21,11 @@
|
|||||||
|
|
||||||
## 3. Internal Host↔Cloud protocol
|
## 3. Internal Host↔Cloud protocol
|
||||||
|
|
||||||
- [ ] 3.1 Add `cancel_requested: bool = False` field to `LeaseRenewalResponse` (`internal_api/models.py`).
|
- [x] 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"]`.
|
- [x] 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.
|
- [x] 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`.
|
- [x] 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.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
|
## 4. Host Agent collaborative stop
|
||||||
|
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ def create_internal_router(
|
|||||||
summary=payload.progress.summary[:500],
|
summary=payload.progress.summary[:500],
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
renewal_status = pool.store.renew_lease(
|
renewal = pool.store.renew_lease(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
attempt=payload.attempt,
|
attempt=payload.attempt,
|
||||||
lease_id=payload.lease_id,
|
lease_id=payload.lease_id,
|
||||||
@@ -325,16 +325,17 @@ def create_internal_router(
|
|||||||
now=now,
|
now=now,
|
||||||
progress=progress_snapshot,
|
progress=progress_snapshot,
|
||||||
)
|
)
|
||||||
if renewal_status == "not_found":
|
if renewal.status == "not_found":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="assignment 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 _stale_lease_conflict("assignment lease is stale or expired")
|
||||||
return LeaseRenewalResponse(
|
return LeaseRenewalResponse(
|
||||||
status="renewed",
|
status="renewed",
|
||||||
lease_expires_at=lease_expires_at,
|
lease_expires_at=lease_expires_at,
|
||||||
|
cancel_requested=renewal.cancel_requested,
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class LeaseRenewalRequest(BaseModel):
|
|||||||
class LeaseRenewalResponse(BaseModel):
|
class LeaseRenewalResponse(BaseModel):
|
||||||
status: Literal["renewed"]
|
status: Literal["renewed"]
|
||||||
lease_expires_at: datetime
|
lease_expires_at: datetime
|
||||||
|
cancel_requested: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TerminalResultRequest(BaseModel):
|
class TerminalResultRequest(BaseModel):
|
||||||
@@ -102,7 +103,7 @@ class TerminalResultRequest(BaseModel):
|
|||||||
task_id: str = Field(min_length=1)
|
task_id: str = Field(min_length=1)
|
||||||
attempt: int = Field(ge=1)
|
attempt: int = Field(ge=1)
|
||||||
lease_id: str = Field(min_length=1)
|
lease_id: str = Field(min_length=1)
|
||||||
status: Literal["done", "failed"]
|
status: Literal["done", "failed", "cancelled"]
|
||||||
failure_reason: str | None = None
|
failure_reason: str | None = None
|
||||||
result: dict[str, Any] | None = None
|
result: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -473,10 +473,32 @@ def test_lease_renewal_extends_active_assignment(tmp_path) -> None:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "renewed"
|
assert response.json()["status"] == "renewed"
|
||||||
|
assert response.json()["cancel_requested"] is False
|
||||||
renewed_expiry = datetime.fromisoformat(response.json()["lease_expires_at"])
|
renewed_expiry = datetime.fromisoformat(response.json()["lease_expires_at"])
|
||||||
assert renewed_expiry > original_time + timedelta(seconds=30)
|
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:
|
def test_stale_renewal_returns_typed_conflict(tmp_path) -> None:
|
||||||
client, pool = _build_client(tmp_path)
|
client, pool = _build_client(tmp_path)
|
||||||
_seed_active_assignment(pool)
|
_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]
|
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:
|
def test_conflicting_repeated_result_returns_stale_lease_conflict(tmp_path) -> None:
|
||||||
client, pool = _build_client(tmp_path)
|
client, pool = _build_client(tmp_path)
|
||||||
_seed_active_assignment(pool)
|
_seed_active_assignment(pool)
|
||||||
|
|||||||
Reference in New Issue
Block a user