Files
agentic-mobile-control/openspec/changes/task-cancellation/tasks.md
T
q792602257 18f053e64b 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.
2026-07-15 19:13:40 +08:00

11 KiB

1. Core and Runtime status plumbing

  • 1.1 Confirm core/models.py's TaskStatus already includes "cancelled" (it does); add StepStatus no change needed — verify no other literal needs widening.
  • 1.2 Add an optional stop_reason: Callable[[], str] | None parameter to TaskRunner.run() (runtime/task.py), defaulting to None.
  • 1.3 Update TaskRunner._interrupt_task() to accept the resolved reason string, set status="cancelled" when the reason indicates cancellation (e.g. contains "cancel"), else keep status="failed" as today, and record the reason as failure_reason in both cases.
  • 1.4 Update WorkflowRunner's stop branch (workflow/runner.py) to accept and pass through the same optional stop_reason, reusing its existing "cancelled" checkpoint call — confirm no behavior change needed since it already lands on "cancelled" for any stop; only wire the reason through for consistency/logging. (Correction during implementation: WorkflowRunner previously collapsed every stop, including lease-loss, into "cancelled", which contradicts the host-agent-protocol spec's requirement to distinguish cancellation from lease-loss stops. _stop_status() now branches on stop_reason the same way TaskRunner does, while preserving the exact pre-existing default ("cancelled") when no stop_reason is supplied.)
  • 1.5 Add/extend unit tests in tests/ for TaskRunner covering: cancellation-flavored stop → status="cancelled"; lease-loss-flavored stop → status="failed" (existing behavior preserved). Also added matching WorkflowRunner coverage for the same _stop_status branching.

2. Cloud persistence: schema and repository

  • 2.1 Add Alembic migration 0012_task_cancellation.py under packages/cloud-platform/cloud/migrations/versions/: add nullable cancel_requested_at column to scheduled_tasks, matching the style of 0008_task_progress_columns.
  • 2.2 Add cancel_requested_at to ScheduledTaskRow (db_models.py) and to the ScheduledTask dataclass (cloud/scheduler.py).
  • 2.3 Widen ScheduledTaskStatus (cloud/scheduler.py) to include "cancelled".
  • 2.4 Widen TerminalTaskStatus and record_task_result's accepted status literal (repository.py Protocol, sql_repository.py) to include "cancelled"; ensure the idempotency logic (already_recorded/conflict) treats a repeated "cancelled" report the same way it treats repeated "done"/"failed" reports today.
  • 2.5 Add CancellationRequestStatus = Literal["requested", "already_terminal", "already_requested", "not_found"] and a request_task_cancellation(task_id, *, requested_at) -> CancellationRequestStatus method to the CloudRepository Protocol.
  • 2.6 Implement request_task_cancellation in SQLAlchemyCloudRepository: for queued tasks, transition directly to status="cancelled"; for assigned/dispatched tasks, set cancel_requested_at if unset (return "already_requested" if already set); for done/failed/cancelled tasks, return "already_terminal"/success-idempotent as appropriate per design D7; for unknown task id, return "not_found".
  • 2.7 Extend renew_lease (sql_repository.py) to read cancel_requested_at on the current row and report whether it is set, without changing its existing lease-extension/progress-update behavior.
  • 2.8 Update reap_expired_leases so a task whose cancel_requested_at is set resolves to status="cancelled" (clearing cancel_requested_at) instead of being requeued to queued, regardless of remaining attempts.
  • 2.9 Ensure record_task_result and the immediate queued-cancellation path both clear cancel_requested_at on reaching any terminal status.
  • 2.10 Add/extend repository-level tests (SQLite, matching existing test style) covering: immediate queued cancel; durable cancel request on assigned/dispatched surviving a simulated restart (re-fetch); renewal reporting the pending flag; expired lease with pending cancellation resolving to cancelled; idempotent repeat cancel calls; cancel rejected on done/failed.

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.

4. Host Agent collaborative stop

  • 4.1 Extend LeaseGuard (host_agent/lease.py) with a reason attribute already implied by mark_lost(reason) — confirm it's readable, add a is_cancellation helper or convention (e.g. reason string prefix) to distinguish cancellation from other lost-lease reasons.
  • 4.2 Update ActiveAssignmentRunner._renew_while_running() (host_agent/lease.py): when response.cancel_requested is true, call guard.mark_lost("cancellation requested by control plane") instead of continuing the renewal loop.
  • 4.3 Update client.py's renew() to ensure LeaseRenewalResponse.cancel_requested deserializes correctly (should be automatic via Pydantic model update, but add a test).
  • 4.4 Update AssignmentExecutor._execute_goal() and _execute_workflow() (host_agent/assignment.py) to map a cancellation-flavored stop to AssignmentExecutionResult.status = "cancelled" (new value alongside "done"/"failed"), reading the underlying Task/WorkflowRun status ("cancelled") instead of collapsing it to "failed".
  • 4.5 Update AssignmentProcessor.process() (host_agent/processor.py) so its status = "done" if execution.status == "done" else "failed" mapping becomes a three-way mapping that preserves "cancelled", and report_result is called with status="cancelled" in that case.
  • 4.6 Add/extend Host Agent tests covering: a renewal response with cancel_requested=True stops the active should_stop-driven loop; the resulting AssignmentExecutionResult.status and reported terminal status are "cancelled"; a lease-loss stop unrelated to cancellation still reports "failed".

5. Public SDK endpoint

  • 5.1 Add POST /v1/tasks/{task_id}/cancel route to cloud/sdk/api.py, scope-gated by TASKS_SUBMIT_SCOPE, calling the new TaskScheduler/repository cancellation operation.
  • 5.2 Add TaskCancellationResponse {task_id, status} model to cloud/sdk/models.py (or wherever SDK response models live); return 200 OK for immediate/already-terminal-cancelled idempotent cases, 202 Accepted for a newly recorded pending cancellation, 404 for unknown task id, 409 Conflict for a done/failed task.
  • 5.3 Widen the status_filter Literal on list_tasks (cloud/sdk/api.py) to include "cancelled".
  • 5.4 Add a cancel_task(task_id) method to CloudClient (cloud/sdk/client.py) mirroring the new route.
  • 5.5 Add/extend SDK-level tests: submit-scoped caller cancels a queued task (200, immediate); cancels a dispatched task (202, pending); read-only-scoped caller rejected before reaching the scheduler; cancel on unknown id (404); cancel on terminal id (409); repeat cancel calls idempotent (200).

6. Frontend: Cloud Console

  • 6.1 Add "cancelled" to TaskStatus in cloud-console/src/types.ts and to the STATUSES array in TasksView.vue.
  • 6.2 Add a cancelTask(taskId) method to cloud-console/src/api.ts.
  • 6.3 Add a "Cancel" button to TasksView.vue's task detail panel, visible only when the selected task's status is queued/assigned/dispatched and the operator's token has tasks:submit; on click, call cancelTask and refresh the displayed task.
  • 6.4 Add/extend Cloud Console component tests (existing test style) covering: Cancel button visibility per status/scope; successful cancel updates displayed status; error response is surfaced without falsely showing cancelled. (Project has no Vue component-mounting test harness — @vue/test-utils isn't a dependency and no existing test exercises a .vue file directly. Followed the established pattern instead: extracted the visibility rule into a pure, unit-tested taskCancellation.ts module — mirroring taskProgress.ts/plannerHistory.ts — covering cancellable vs. terminal statuses and the tasks:submit scope gate. cancelSelectedTask in TasksView.vue only mutates selectedTask.status on a successful response and routes failures through the existing handleError/errorMessage path, so an error never flips the displayed status to cancelled.)

7. Frontend: Host Agent local console

  • 7.1 Add a POST /tasks/{id}/cancel route to the Host Agent local console (host_agent/web/app.py) that calls through to the same cancellation path used by the collaborative-stop mechanism for a locally-tracked task, consistent with existing local console read routes. (Correction during implementation: the Host Agent's own internal-API bearer credential — issued by RepositoryHostAuthProvider — carries an empty scopes frozenset and is authorized only via Principal.require_host() identity checks, not scopes. It therefore cannot call the public SDK's tasks:submit-scoped POST /v1/tasks/{task_id}/cancel endpoint from design.md/section 5. Added a new internal API route, POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel (cloud/internal_api/api.py), authenticated the same way as the existing host self-submission route (authorize_host), with an added ownership check rejecting tasks whose constraints.target_host_id != host_id with 404. This mirrors the pre-existing pattern where hosts already self-serve create/execute their own tasks over this credential, and does not conflict with design.md's Non-Goal — that constraint (no per-task ACL; anyone with tasks:submit can cancel any task) is scoped to the public SDK layer, not the internal Host↔Cloud API. HostAgentClient.cancel_task() calls this new route directly rather than proxying to the public SDK.)
  • 7.2 Add a Cancel button to templates/task_detail.html for tasks not yet in a terminal state.
  • 7.3 Add/extend local console tests covering the new route and template rendering.

8. Docs

  • 8.1 Add a short section to docs/CLOUD_DEPLOYMENT.md documenting that cancellation is collaborative (not instantaneous) for assigned/dispatched tasks, bounded by roughly one third of the configured lease duration, with immediate effect for queued tasks.

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.