Files
agentic-mobile-control/openspec/changes/task-cancellation/tasks.md
T
q792602257 88189770ff feat(runtime): add cancellation-aware stop_reason to TaskRunner and WorkflowRunner
- TaskRunner.run() and WorkflowRunner.run()/resume() accept an optional
  stop_reason callable alongside should_stop, distinguishing a genuine
  cancellation from other stop conditions (e.g. lost lease).
- is_cancellation_reason() shared helper added to runtime/task.py.
- WorkflowRunner._stop_status() now branches cancelled/failed based on
  stop_reason, correcting a prior blanket cancelled-on-any-stop behavior
  that conflicted with the host-agent-protocol spec's requirement to
  distinguish cancellation from lease-loss stops.
- Default behavior (stop_reason=None) is preserved exactly for both
  runners so existing callers/tests are unaffected.
- Task 1 of openspec change task-cancellation.
2026-07-15 17:52:33 +08:00

9.4 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.

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.
  • 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.