fix: recover failed task launches safely (#3437)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user