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:
@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user