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:
@ -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 {
|
||||
|
||||
@ -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