fix(runtime): cancel de background task immédiat et fiable depuis le workstate (#199, QA verte)

Transforme l'annulation d'une background task en contrat immédiat, observable et non-best-effort:

- domain/background_task: états Cancelling/Cancelled/CancelFailed et transitions idempotentes; un kill PTY qui échoue est surfacé en CancelFailed plutôt qu'en succès silencieux.

- infrastructure/runner + store: cancel effectif de la task ciblée, reconcile marque Cancelling même sans live handle, et départage CancelFailed des tâches pending au boot.

- application/background + workstate + orchestrator/wake: chemin de cancel initié depuis le workstate, projection synchronisée de l'état (pas d'attente d'un cycle ambigu).

- frontend: projection workstate de l'état cancelled/cancelling/cancelFailed (cancellingRow/cancelFailedRow/cancelButtons), normalization et services.

Tests QA verts: background_task_cancel, background_task_runner cancel_wins_even_if_output_is_still_available, background_task_store reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending, workstate.test.tsx.
This commit is contained in:
2026-08-12 15:18:38 +02:00
parent f5e2e302ba
commit 46103397de
24 changed files with 699 additions and 76 deletions

View File

@ -92,10 +92,14 @@ function formatTaskStatus(status: BackgroundCompletion["status"]): string {
switch (status) {
case "running":
return "Running";
case "cancelling":
return "Cancelling";
case "completed":
return "Completed";
case "failed":
return "Failed";
case "cancelFailed":
return "Cancel failed";
case "cancelled":
return "Cancelled";
case "delivered":
@ -107,16 +111,33 @@ function formatTaskStatus(status: BackgroundCompletion["status"]): string {
/** Terminal (finished) task states that carry a result summary (ticket #5). */
function isTerminalTaskStatus(status: BackgroundCompletion["status"]): boolean {
return status === "completed" || status === "failed" || status === "cancelled";
return (
status === "completed" ||
status === "failed" ||
status === "cancelFailed" ||
status === "cancelled"
);
}
function taskStatusClass(status: BackgroundCompletion["status"]): string {
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
if (status === "running" || status === "pending" || status === "cancelling") {
return "bg-warning/15 text-warning";
}
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger";
if (status === "failed" || status === "cancelFailed" || status === "cancelled") {
return "bg-danger/10 text-danger";
}
return "bg-raised text-muted";
}
function canCancelTask(status: BackgroundCompletion["status"]): boolean {
return status === "running" || status === "pending" || status === "cancelling";
}
function canRetryTask(status: BackgroundCompletion["status"]): boolean {
return status === "failed" || status === "cancelFailed" || status === "cancelled";
}
function requesterLabel(ticket: AgentTicketState): string {
if (ticket.source.kind === "human") return "Human";
const id = shortTicket(ticket.source.agentId);
@ -346,8 +367,8 @@ function BackgroundTaskRow({
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
const attachSeqRef = useRef(0);
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
const canCancel = task.status === "running" || task.status === "pending";
const canRetry = task.status === "failed" || task.status === "cancelled";
const canCancel = canCancelTask(task.status);
const canRetry = canRetryTask(task.status);
const canAttachLive = canCancel;
function detachLive(): void {