Merge branch 'worktree-task-cancellation': task cancellation feature
Tests / Test passed: 926

# Conflicts:
#	packages/cloud-platform/cloud/schema.py
This commit is contained in:
2026-07-15 19:39:28 +08:00
49 changed files with 2374 additions and 63 deletions
@@ -41,7 +41,9 @@ CSRF_FORM_FIELD = "csrf_token"
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
TaskSubmissionCallable = Callable[..., Awaitable[str]]
TaskCancellationCallable = Callable[..., Awaitable[Any]]
AUTOMATIC_DEVICE_VALUE = "__automatic__"
_TERMINAL_LOCAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
_ENV = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
@@ -217,6 +219,7 @@ def create_console_app(
enrollment_client: HostAgentEnrollmentClient | None,
host_client: HostAgentClient | None = None,
submit_self_task: TaskSubmissionCallable | None = None,
cancel_task: TaskCancellationCallable | None = None,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
executor: AssignmentExecutor | None = None,
@@ -225,6 +228,8 @@ def create_console_app(
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
if submit_self_task is None and host_client is not None:
submit_self_task = host_client.submit_self_task
if cancel_task is None and host_client is not None:
cancel_task = host_client.cancel_task
submission_available = submit_self_task is not None
def _running_devices() -> list[dict[str, str]]:
@@ -709,6 +714,7 @@ def create_console_app(
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_page(
task_id: str,
request: Request,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
@@ -738,13 +744,57 @@ def create_console_app(
if task.get(key) is not None
]
timeline_steps = [_timeline_step_context(record) for record in timeline_records]
can_cancel = (
cancel_task is not None
and task.get("source_task_id") is not None
and task.get("status") not in _TERMINAL_LOCAL_TASK_STATUSES
)
cancel_notice = (
"Cancellation requested. It may take a moment to take effect."
if request.query_params.get("cancelled") == "1"
else None
)
cancel_error = (
"Failed to request cancellation. Try again."
if request.query_params.get("cancel_error") == "1"
else None
)
return _render(
"task_detail.html",
title=f"Task {task_id}",
session=session,
csrf_token=session.csrf_token,
task=task,
task_rows=task_rows,
timeline_steps=timeline_steps,
can_cancel=can_cancel,
cancel_notice=cancel_notice,
cancel_error=cancel_error,
)
@app.post("/tasks/{task_id}/cancel")
async def tasks_cancel(
task_id: str,
session: SessionState = Depends(require_csrf),
) -> Response:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
task = await asyncio.to_thread(metadata_store.get_task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
source_task_id = task.get("source_task_id")
if cancel_task is None or source_task_id is None:
return RedirectResponse(
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
)
try:
await cancel_task(source_task_id)
except HostAgentAPIError:
return RedirectResponse(
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
)
return RedirectResponse(url=f"/tasks/{task_id}?cancelled=1", status_code=303)
return app
@@ -42,6 +42,14 @@
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
</table>
{% if cancel_notice %}<p class="notice" id="cancel-notice">{{ cancel_notice }}</p>{% endif %}
{% if cancel_error %}<p class="error" id="cancel-error">{{ cancel_error }}</p>{% endif %}
{% if can_cancel %}
<form method="post" action="/tasks/{{ task['id'] }}/cancel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p><button type="submit">Cancel task</button></p>
</form>
{% endif %}
<h2>Timeline</h2>
{% if not timeline_steps %}
<p>No timeline records.</p>