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

@ -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 () => {