fix: recover failed task launches safely (#3437)

This commit is contained in:
Carlos Florêncio
2026-09-07 11:19:09 +01:00
committed by GitHub
parent 3c6af41063
commit 3fba828e2f
69 changed files with 2280 additions and 277 deletions
@@ -54,6 +54,7 @@ function TypedRunLaunchErrorEntry({
stamp: error.errorStamp ?? "",
occurred_at: error.failedAt,
preview,
details: error.failureDetails,
category: error.failureCode,
recovery_actions: error.recoveryActions,
}}
@@ -1,17 +1,16 @@
"use client";
import { useMemo } from "react";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import type { RunError } from "@/app/office/tasks/[id]/types";
import { hasMatchingSessionLaunchError } from "../chat-entries";
import { isTaskLaunchErrorVisibleForSession } from "@/components/task/chat/types";
import { isTypedTaskLaunchError, TaskLaunchErrorEntry } from "./task-launch-error-entry";
type TaskChatLaunchErrorProps = {
taskId: string;
workspaceId: string;
statusSummary?: TaskStatusSummary | null;
runErrors: RunError[];
/** When supplied, only render the error that belongs to this session. */
sessionId?: string | null;
repositories?: TaskRepository[];
};
@@ -19,17 +18,14 @@ export function TaskChatLaunchError({
taskId,
workspaceId,
statusSummary,
runErrors,
sessionId,
repositories,
}: TaskChatLaunchErrorProps) {
const error = useMemo(() => {
const candidate = statusSummary?.active_error;
if (!isTypedTaskLaunchError(candidate)) return null;
if (hasMatchingSessionLaunchError(candidate.session_id, candidate.stamp, runErrors)) {
return null;
}
return candidate;
}, [runErrors, statusSummary]);
const candidate = statusSummary?.active_error;
const error =
isTypedTaskLaunchError(candidate) && isTaskLaunchErrorVisibleForSession(candidate, sessionId)
? candidate
: null;
if (!error) return null;
return (
@@ -43,11 +43,15 @@ vi.mock("@/components/task/task-launch-branch-picker", () => ({
const TASK_ID = "task-1";
const TASK_REPOSITORY_ID = "task-repo-1";
const ERROR_STAMP = "launch-stamp-1";
const WORKSPACE_ID = "workspace-1";
const OCCURRED_AT = "2026-08-19T10:00:00Z";
const ERROR_ENTRY_TEST_ID = "task-launch-error-entry";
const RETRY_LAUNCH_BUTTON_TEST_ID = "task-launch-retry_launch-button";
const error: TaskStatusSummaryActiveError = {
task_repository_id: TASK_REPOSITORY_ID,
stamp: ERROR_STAMP,
occurred_at: "2026-08-19T10:00:00Z",
occurred_at: OCCURRED_AT,
preview: "The selected base branch is not available.",
category: "base_branch_missing",
recovery_actions: ["retry_default", "pick_base_branch", "mark_review_done"],
@@ -78,6 +82,7 @@ describe("TaskLaunchErrorEntry", () => {
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
])("classifies launch category %s as a typed launch error", (category) => {
expect(
@@ -88,30 +93,121 @@ describe("TaskLaunchErrorEntry", () => {
).toBe(true);
});
it("renders checkout details and retries the launch without branch settings", async () => {
const checkoutError: TaskStatusSummaryActiveError = {
session_id: "session-1",
stamp: "checkout-stamp-1",
occurred_at: OCCURRED_AT,
preview: "The workspace could not be prepared for this launch.",
details: "The pull request head was fetched into its isolated ref.",
category: "workspace_checkout_failed",
recovery_actions: ["retry_launch"],
};
render(
<TaskLaunchErrorEntry taskId={TASK_ID} workspaceId={WORKSPACE_ID} error={checkoutError} />,
);
expect(screen.getByText(checkoutError.preview)).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /show details/i }));
expect(screen.getByText(checkoutError.details!)).toBeTruthy();
expect(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID)).toBeTruthy();
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("task.launch.recover", {
task_id: TASK_ID,
session_id: "session-1",
action: "retry_launch",
error_stamp: "checkout-stamp-1",
});
});
it("keeps recovery failures inside the launch card", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
expect(toastMock).not.toHaveBeenCalled();
});
it("clears a recovery failure when a newer launch error arrives", async () => {
requestMock.mockRejectedValueOnce(new Error("recovery failed"));
const { rerender } = render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{ ...error, recovery_actions: ["retry_launch"] }}
/>,
);
fireEvent.click(screen.getByTestId(RETRY_LAUNCH_BUTTON_TEST_ID));
await waitFor(() => expect(screen.getByTestId("task-launch-recovery-error")).toBeTruthy());
rerender(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
error={{
...error,
stamp: "launch-stamp-2",
recovery_actions: ["retry_launch"],
}}
/>,
);
await waitFor(() => expect(screen.queryByTestId("task-launch-recovery-error")).toBeNull());
});
it("renders a typed summary error without recovery actions", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
statusSummary={{
revision: 1,
updated_at: "2026-08-19T10:00:00Z",
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: [] },
}}
runErrors={[]}
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
expect(screen.queryByTestId("task-launch-retry_default-button")).toBeNull();
});
it("renders a task-wide launch error while a prior session is selected", () => {
render(
<TaskChatLaunchError
taskId={TASK_ID}
workspaceId={WORKSPACE_ID}
sessionId="prior-session"
statusSummary={{
revision: 1,
updated_at: OCCURRED_AT,
active_error: { ...error, recovery_actions: ["mark_review_done"] },
}}
/>,
);
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByTestId("task-launch-mark_review_done-button")).toBeTruthy();
});
it("renders the bounded preview and sends the exact task recovery payload", async () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[
{
id: TASK_REPOSITORY_ID,
@@ -127,7 +223,7 @@ describe("TaskLaunchErrorEntry", () => {
/>,
);
expect(screen.getByTestId("task-launch-error-entry")).toBeTruthy();
expect(screen.getByTestId(ERROR_ENTRY_TEST_ID)).toBeTruthy();
expect(screen.getByText(error.preview)).toBeTruthy();
fireEvent.click(screen.getByTestId("task-launch-retry_default-button"));
@@ -144,7 +240,7 @@ describe("TaskLaunchErrorEntry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
@@ -165,7 +261,7 @@ describe("TaskLaunchErrorEntry", () => {
render(
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>,
@@ -187,13 +283,13 @@ describe("TaskLaunchErrorEntry", () => {
<>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
<TaskLaunchErrorEntry
taskId={TASK_ID}
workspaceId="workspace-1"
workspaceId={WORKSPACE_ID}
repositories={[]}
error={error}
/>
@@ -210,6 +306,6 @@ describe("TaskLaunchErrorEntry", () => {
expect(buttons[1].getAttribute("disabled")).not.toBeNull();
});
resolveRequest?.({ ok: true });
await waitFor(() => expect(screen.queryAllByTestId("task-launch-error-entry")).toHaveLength(2));
await waitFor(() => expect(screen.queryAllByTestId(ERROR_ENTRY_TEST_ID)).toHaveLength(2));
});
});
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
IconAlertTriangle,
IconCheck,
@@ -9,7 +9,7 @@ import {
IconRefresh,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useToast } from "@/components/toast-provider";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { getWebSocketClient } from "@/lib/ws/connection";
import type { TaskRepository } from "@/lib/types/http";
import type { TaskLaunchRecoveryAction } from "@/lib/types/task-launch-error";
@@ -26,10 +26,64 @@ type TaskLaunchErrorEntryProps = {
const pendingRecoveryRequests = new Map<string, Promise<unknown>>();
function useTaskLaunchRecovery({
taskId,
error,
}: Pick<TaskLaunchErrorEntryProps, "taskId" | "error">) {
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
const [recoveryError, setRecoveryError] = useState(false);
useEffect(() => {
setRecoveryError(false);
}, [error.stamp]);
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
const client = getWebSocketClient();
if (!client || pendingAction) return;
setRecoveryError(false);
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
const existingRequest = pendingRecoveryRequests.get(requestKey);
if (existingRequest) {
setPendingAction(action);
try {
await existingRequest;
} catch {
// The request owner reports the recovery error to avoid duplicate toasts.
} finally {
setPendingAction(null);
}
return;
}
setPendingAction(action);
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
error_stamp: error.stamp,
}),
);
pendingRecoveryRequests.set(requestKey, request);
try {
await request;
} catch {
setRecoveryError(true);
} finally {
pendingRecoveryRequests.delete(requestKey);
setPendingAction(null);
}
};
return { pendingAction, recoveryError, sendRecovery };
}
const TASK_LAUNCH_ERROR_CATEGORIES = new Set([
"base_branch_missing",
"default_branch_unresolved",
"pr_already_closed",
"workspace_checkout_failed",
"generic_launch_failure",
]);
@@ -51,6 +105,8 @@ function categoryTitle(category: string | undefined, t: (key: string) => string)
return t("task:launchErrorDefaultBranchUnresolved");
case "pr_already_closed":
return t("task:launchErrorPrAlreadyClosed");
case "workspace_checkout_failed":
return t("task:launchErrorWorkspaceCheckoutFailed");
default:
return t("task:launchErrorGeneric");
}
@@ -64,6 +120,8 @@ function actionLabel(action: TaskLaunchRecoveryAction, t: (key: string) => strin
return t("task:pickBaseBranch");
case "mark_review_done":
return t("task:markReviewDone");
case "retry_launch":
return t("task:retryLaunch");
}
}
@@ -76,6 +134,7 @@ function RecoveryActionIcon({
}) {
if (pending) return <IconLoader2 className="h-3 w-3 animate-spin" />;
if (action === "retry_default") return <IconRefresh className="h-3 w-3" />;
if (action === "retry_launch") return <IconRefresh className="h-3 w-3" />;
if (action === "pick_base_branch") return <IconGitBranch className="h-3 w-3" />;
return <IconCheck className="h-3 w-3" />;
}
@@ -99,14 +158,14 @@ function TaskLaunchRecoveryActions({
}) {
const { t } = useTranslation();
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2">
<div className="mt-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
{actions.map((action) => {
const opensBranchPicker = action === "pick_base_branch";
const button = (
<Button
variant="outline"
size="sm"
className="h-auto min-h-11 max-w-full cursor-pointer gap-1.5 text-xs sm:min-h-8"
className="h-auto min-h-11 w-full cursor-pointer justify-start gap-1.5 text-xs sm:w-auto sm:min-h-8"
disabled={pendingAction !== null}
onClick={opensBranchPicker ? undefined : () => void onRecover(action)}
data-testid={`task-launch-${action}-button`}
@@ -139,58 +198,16 @@ export function TaskLaunchErrorEntry({
repositories,
}: TaskLaunchErrorEntryProps) {
const { t } = useTranslation();
const { toast } = useToast();
const [pendingAction, setPendingAction] = useState<TaskLaunchRecoveryAction | null>(null);
const { pendingAction, recoveryError, sendRecovery } = useTaskLaunchRecovery({ taskId, error });
const [showDetails, setShowDetails] = useState(false);
const taskRepository = repositories?.find(
(repository) => repository.id === error.task_repository_id,
);
const currentBase = taskRepository?.base_branch ?? t("task:baseBranchNotSet");
const sendRecovery = async (action: TaskLaunchRecoveryAction, baseBranch?: string) => {
const client = getWebSocketClient();
if (!client || pendingAction) return;
const requestKey = JSON.stringify({ taskId, stamp: error.stamp, action, baseBranch });
const existingRequest = pendingRecoveryRequests.get(requestKey);
if (existingRequest) {
setPendingAction(action);
try {
await existingRequest;
} catch {
// The request owner reports the recovery error to avoid duplicate toasts.
} finally {
setPendingAction(null);
}
return;
}
setPendingAction(action);
const request = Promise.resolve().then(() =>
client.request("task.launch.recover", {
task_id: taskId,
...(error.session_id ? { session_id: error.session_id } : {}),
...(error.task_repository_id ? { task_repository_id: error.task_repository_id } : {}),
action,
...(baseBranch ? { base_branch: baseBranch } : {}),
error_stamp: error.stamp,
}),
);
pendingRecoveryRequests.set(requestKey, request);
try {
await request;
} catch {
toast({
title: t("task:launchRecoveryFailed"),
description: t("task:tryRecoveryAgain"),
variant: "error",
});
} finally {
pendingRecoveryRequests.delete(requestKey);
setPendingAction(null);
}
};
return (
<div
className="flex min-w-0 gap-3 border-b border-border/50 py-3"
className="flex min-w-0 gap-3 rounded-md border border-destructive/30 bg-destructive/5 p-3 sm:p-4"
data-testid="task-launch-error-entry"
role="alert"
>
@@ -205,6 +222,27 @@ export function TaskLaunchErrorEntry({
<p className="mt-1 max-w-prose break-words text-sm text-muted-foreground">
{error.preview}
</p>
<p className="mt-2 text-xs text-muted-foreground" data-testid="task-launch-no-change">
{t("task:launchErrorNoChanges")}
</p>
{error.details && (
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="mt-3">
<CollapsibleTrigger className="flex min-h-11 items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground cursor-pointer sm:min-h-8">
<IconRefresh
className={`h-3.5 w-3.5 transition-transform ${showDetails ? "rotate-90" : ""}`}
/>
{t("task:showDetails")}
</CollapsibleTrigger>
<CollapsibleContent>
<pre
className="mt-2 max-w-prose whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed text-muted-foreground"
data-testid="task-launch-error-details"
>
{error.details}
</pre>
</CollapsibleContent>
</Collapsible>
)}
<TaskLaunchRecoveryActions
actions={error.recovery_actions ?? []}
workspaceId={workspaceId}
@@ -214,6 +252,15 @@ export function TaskLaunchErrorEntry({
pendingAction={pendingAction}
onRecover={sendRecovery}
/>
{recoveryError && (
<p
className="mt-2 text-xs text-destructive"
data-testid="task-launch-recovery-error"
role="alert"
>
{t("task:launchRecoveryFailed")} {t("task:tryRecoveryAgain")}
</p>
)}
</div>
</div>
);