diff --git a/openspec/changes/task-cancellation/tasks.md b/openspec/changes/task-cancellation/tasks.md index e47a218..ee17476 100644 --- a/openspec/changes/task-cancellation/tasks.md +++ b/openspec/changes/task-cancellation/tasks.md @@ -63,5 +63,5 @@ ## 9. End-to-end verification -- [ ] 9.1 Run the full test suite (`uv run pytest` at repo root, plus `cloud-console` frontend tests) and confirm no regressions in existing task-scheduler, host-agent-protocol, platform-sdk, or workflow-orchestration tests. -- [ ] 9.2 Manually or via an integration test, exercise the full path: submit a task, cancel a `queued` task (immediate), submit and dispatch another task, cancel it mid-execution, and confirm it reaches `cancelled` within one lease-renewal cycle with `cancelled` visible in both the public API and the Cloud Console. +- [x] 9.1 Run the full test suite (`uv run pytest` at repo root, plus `cloud-console` frontend tests) and confirm no regressions in existing task-scheduler, host-agent-protocol, platform-sdk, or workflow-orchestration tests. (869 passed, 50 skipped, 4 pre-existing failures unrelated to this change — `test_verifier_against_real_llm`, `test_reflector_against_real_llm`, `test_real_anthropic_ai_planner_selects_a_tool`, `test_real_anthropic_semantic_enrichment_returns_schema_valid_scene` all require live Anthropic API network access and fail the same way on `master`. `cloud-console`: 27 tests passed, `vue-tsc --noEmit` typecheck clean.) +- [x] 9.2 Manually or via an integration test, exercise the full path: submit a task, cancel a `queued` task (immediate), submit and dispatch another task, cancel it mid-execution, and confirm it reaches `cancelled` within one lease-renewal cycle with `cancelled` visible in both the public API and the Cloud Console. (Added `test_cancellation_full_path_queued_immediate_and_dispatched_collaborative` in `tests/test_cloud_sdk_api.py`: submits and immediately cancels a queued task via `POST /v1/tasks/{id}/cancel` (200, `cancelled`); submits, dispatches, and cancels a second task mid-execution (202, pending); drives one lease renewal confirming `cancel_requested=True` is surfaced; reports a `cancelled` terminal result as the Host Agent would; and confirms the task shows `status="cancelled"` via both `GET /v1/tasks/{id}` and `GET /v1/tasks?status=cancelled`. The Cloud Console reads task status through this same public API and its `cancelled` rendering is covered by the Task 6.4 `taskCancellation.ts` unit tests, so this repository-to-API round trip is the full path exercised at the automated-test layer; no manual browser session was run.) diff --git a/tests/test_cloud_sdk_api.py b/tests/test_cloud_sdk_api.py index 788d823..9819f82 100644 --- a/tests/test_cloud_sdk_api.py +++ b/tests/test_cloud_sdk_api.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest @@ -620,6 +620,69 @@ def test_cancel_scope_rejected_before_reaching_scheduler(tmp_path, monkeypatch) assert called is False +def test_cancellation_full_path_queued_immediate_and_dispatched_collaborative( + tmp_path, +) -> None: + """End-to-end exercise of the cancellation path (task-cancellation 9.2): + a queued task is cancelled immediately; a dispatched task's cancellation + is only recorded until the Host Agent's next lease renewal surfaces it + and reports back a cancelled terminal result, after which the task is + visible as cancelled via the public API's get and list endpoints.""" + app, pool, scheduler, _ = _build_app(tmp_path) + pool.sync_host_devices( + "host-a", + [Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type] + ) + client = _client_for(app) + + queued_task_id = scheduler.submit(goal="cancel me while queued") + queued_cancel = client.post(f"/v1/tasks/{queued_task_id}/cancel") + assert queued_cancel.status_code == 200, queued_cancel.text + assert queued_cancel.json() == {"task_id": queued_task_id, "status": "cancelled"} + + dispatched_task_id = scheduler.submit(goal="cancel me mid-execution") + scheduler.assign() + dispatched_cancel = client.post(f"/v1/tasks/{dispatched_task_id}/cancel") + assert dispatched_cancel.status_code == 202, dispatched_cancel.text + assert dispatched_cancel.json() == { + "task_id": dispatched_task_id, + "status": "assigned", + } + + task = scheduler.store.get_task(dispatched_task_id) + renewed_at = datetime.now(UTC) + renewal = scheduler.store.renew_lease( + task_id=dispatched_task_id, + attempt=task.attempt_count, + lease_id=task.lease_id or "", + host_id=task.assigned_host_id or "", + lease_expires_at=renewed_at + timedelta(seconds=30), + now=renewed_at, + ) + assert renewal.status == "renewed", renewal + assert renewal.cancel_requested is True + + scheduler.store.record_task_result( + task_id=dispatched_task_id, + attempt=task.attempt_count, + lease_id=task.lease_id or "", + host_id=task.assigned_host_id or "", + status="cancelled", + failure_reason="cancellation requested by control plane", + terminal_result=None, + completed_at=datetime.now(UTC), + ) + + status_resp = client.get(f"/v1/tasks/{dispatched_task_id}") + assert status_resp.status_code == 200, status_resp.text + assert status_resp.json()["status"] == "cancelled" + + list_resp = client.get("/v1/tasks", params={"status": "cancelled"}) + assert list_resp.status_code == 200, list_resp.text + cancelled_ids = {t["id"] for t in list_resp.json()["items"]} + assert {queued_task_id, dispatched_task_id} <= cancelled_ids + + def test_plugin_admin_scope_is_checked_before_registration( tmp_path, monkeypatch,