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

@ -3,7 +3,7 @@ import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
export type ProjectActivityStatus = "none" | "idle" | "busy";
const ACTIVE_BACKGROUND_STATUSES: ReadonlySet<BackgroundCompletion["status"]> =
new Set(["pending", "running"]);
new Set(["pending", "running", "cancelling"]);
export function projectActivityStatus(
state: ProjectWorkState | null | undefined,

View File

@ -507,17 +507,23 @@ function AgentLiveRow({
const TASK_STATUS_LABEL: Record<BackgroundCompletion["status"], string> = {
running: "Running",
cancelling: "Cancelling",
completed: "Completed",
failed: "Failed",
cancelFailed: "Cancel failed",
cancelled: "Cancelled",
pending: "Pending",
delivered: "Delivered",
};
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";
}

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 {

View File

@ -224,6 +224,21 @@ describe("ProjectWorkStatePanel", () => {
createdAtMs: 16,
updatedAtMs: 17,
},
{
taskId: "task-cancelling-1",
kind: "command",
state: "cancelling",
createdAtMs: 18,
updatedAtMs: 19,
},
{
taskId: "task-cancel-failed-1",
kind: "command",
state: "cancelFailed",
summary: "kill denied",
createdAtMs: 20,
updatedAtMs: 21,
},
],
},
],
@ -237,6 +252,8 @@ describe("ProjectWorkStatePanel", () => {
["task-queued-1", "pending"],
["task-failed-1", "failed"],
["task-expired-1", "cancelled"],
["task-cancelling-1", "cancelling"],
["task-cancel-failed-1", "cancelFailed"],
]);
// Optional fields omitted by the backend normalize to null (not undefined/NaN).
expect(tasks[0]?.exitCode).toBeNull();
@ -249,25 +266,44 @@ describe("ProjectWorkStatePanel", () => {
expect(tasks[1]?.targetAgentId).toBe("agent-bg");
expect(tasks[1]?.conversationId).toBe("conversation-rdv");
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17, 19, 21]);
renderPanel(workState);
const list = await screen.findByLabelText("Runner background tasks");
const cancelButtons = within(list).getAllByRole("button", { name: "Cancel" });
const retryButtons = within(list).getAllByRole("button", { name: "Retry" });
// waiting + queued are cancellable; failed + expired are retryable.
expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(2);
expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(2);
// waiting + queued + cancelling are cancellable; failed + expired + cancelFailed are retryable.
expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3);
expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3);
expect(list.textContent).toContain("Cancelling");
expect(list.textContent).toContain("Cancel failed");
const rows = within(list).getAllByRole("listitem");
const rowTexts = rows.map((row) => row.textContent ?? "");
expect(rowTexts).toHaveLength(6);
const cancelFailedRow = rows[0] as HTMLElement;
const cancellingRow = rows[1] as HTMLElement;
expect(within(cancellingRow).getByText("Cancelling")).toBeTruthy();
expect(
(within(cancellingRow).getByRole("button", {
name: "Cancel",
}) as HTMLButtonElement).disabled,
).toBe(false);
expect(within(cancelFailedRow).getByText("Cancel failed")).toBeTruthy();
expect(
(within(cancelFailedRow).getByRole("button", {
name: "Retry",
}) as HTMLButtonElement).disabled,
).toBe(false);
// Rows are ordered most-recently-updated first (updatedAtMs desc), taskId as
// the deterministic tiebreak — locks the fix for the dead finishedAtMs sort.
const text = list.textContent ?? "";
const order = ["task-exp", "task-fai", "task-que", "task-wai"].map((id) =>
text.indexOf(id),
);
expect(order).toEqual([...order].sort((a, b) => a - b));
expect(order.every((i) => i >= 0)).toBe(true);
expect(rowTexts[0]).toContain("Cancel failed");
expect(rowTexts[1]).toContain("Cancelling");
expect(rowTexts[2]).toContain("Cancelled");
expect(rowTexts[3]).toContain("Failed");
expect(rowTexts[4]).toContain("Pending");
expect(rowTexts[5]).toContain("Pending");
});
it("shows a terminal task summary and hides it when absent or non-terminal (#5)", async () => {