FN-8167: suppress failed UI during automatic recovery
Prevent transient automatic-retry tasks from appearing as terminal failures. - Centralize pending-recovery and manual-retry presentation rules. - Suppress failed styling, failure alerts, and Retry actions across list, card, and detail views. - Cover recovery timing and desktop/mobile task surfaces with regression tests. Files changed: .changeset/fn-8167-transient-retry-affordance.md | 7 ++ packages/dashboard/app/components/ListView.tsx | 16 ++--- packages/dashboard/app/components/TaskCard.tsx | 13 ++-- .../dashboard/app/components/TaskDetailModal.tsx | 13 ++-- .../app/components/__tests__/ListView.test.tsx | 38 +++++++++++ .../app/components/__tests__/TaskCard.test.tsx | 22 +++++++ .../__tests__/TaskDetailModal.rendering.test.tsx | 26 ++++++++ .../app/utils/__tests__/taskRecovery.test.ts | 77 ++++++++++++++++++++++ packages/dashboard/app/utils/taskRecovery.ts | 32 +++++++++ 9 files changed, 215 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-8167 Fusion-Task-Lineage: 60d599e2-2e0e-46e7-ad16-cc6a836b5ac7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8167-transient-retry-affordance.md
Normal file
7
.changeset/fn-8167-transient-retry-affordance.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Don't show tasks as failed with Retry while an automatic transient retry is pending.
|
||||||
|
category: fix
|
||||||
|
dev: Uses the shared dashboard taskRecovery predicate for recovery-state presentation.
|
||||||
@@ -17,6 +17,7 @@ import { QuickEntryBox } from "./QuickEntryBox";
|
|||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { NodeHealthDot } from "./NodeHealthDot";
|
import { NodeHealthDot } from "./NodeHealthDot";
|
||||||
import { isTaskStuck } from "../utils/taskStuck";
|
import { isTaskStuck } from "../utils/taskStuck";
|
||||||
|
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { useViewportMode } from "../hooks/useViewportMode";
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||||
@@ -1704,14 +1705,7 @@ export function ListView({
|
|||||||
}, [addToast, onTasksUpdated, t]);
|
}, [addToast, onTasksUpdated, t]);
|
||||||
|
|
||||||
const buildListContextMenuActions = useCallback((task: Task): TaskMenuActionDescriptor[] => {
|
const buildListContextMenuActions = useCallback((task: Task): TaskMenuActionDescriptor[] => {
|
||||||
const canRetryTask =
|
const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs);
|
||||||
task.status === "failed" ||
|
|
||||||
task.status === "stuck-killed" ||
|
|
||||||
task.status === "planning" ||
|
|
||||||
task.status === "needs-replan" ||
|
|
||||||
(task.stuckKillCount ?? 0) > 0 ||
|
|
||||||
(task.recoveryRetryCount ?? 0) > 0 ||
|
|
||||||
Boolean(task.nextRecoveryAt);
|
|
||||||
const isTaskPaused = Boolean(task.paused || task.userPaused);
|
const isTaskPaused = Boolean(task.paused || task.userPaused);
|
||||||
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMerge ?? false });
|
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMerge ?? false });
|
||||||
const model = buildTaskActionMenuModel({
|
const model = buildTaskActionMenuModel({
|
||||||
@@ -1846,7 +1840,7 @@ export function ListView({
|
|||||||
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
|
actions.push({ id: model.reviewAction.id, label: model.reviewAction.label, disabled: model.reviewAction.disabled, onSelect: model.reviewAction.onSelect });
|
||||||
}
|
}
|
||||||
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
return actions.filter((action) => action.tone === "note" || action.disabled === true || Boolean(action.onSelect));
|
||||||
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
|
}, [addToast, autoMerge, columnFlagsById, confirm, getListColumnLabel, getTaskPlanningWorkflowId, handleListContextCheckPrStatus, handleListContextEnableGithubTracking, handleListContextMove, handleListTaskArchive, handleListTaskDelete, handleListTaskRevert, isMobile, lastFetchTimeMs, listContextMenuColumns, mergeStrategy, onDuplicateTask, onMergeTask, onOpenDetail, onPlanningMode, onPauseTask, onResetTask, onRetryTask, onUnpauseTask, onArchiveTask, onRevertTask, onTasksUpdated, projectId, t, useSinglePaneList]);
|
||||||
|
|
||||||
const contextMenuActions = useMemo(
|
const contextMenuActions = useMemo(
|
||||||
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
() => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []),
|
||||||
@@ -2671,7 +2665,7 @@ export function ListView({
|
|||||||
columnTasks.map((task) => {
|
columnTasks.map((task) => {
|
||||||
const isDoneColumn = isCompleteColumn(task.column);
|
const isDoneColumn = isCompleteColumn(task.column);
|
||||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||||
const isFailed = !isDoneColumn && task.status === "failed";
|
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
|
||||||
const isPaused = !isDoneColumn && task.paused === true;
|
const isPaused = !isDoneColumn && task.paused === true;
|
||||||
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
|
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
|
||||||
@@ -2884,7 +2878,7 @@ export function ListView({
|
|||||||
columnTasks.map((task) => {
|
columnTasks.map((task) => {
|
||||||
const isDoneColumn = isCompleteColumn(task.column);
|
const isDoneColumn = isCompleteColumn(task.column);
|
||||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||||
const isFailed = !isDoneColumn && task.status === "failed";
|
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
|
||||||
const isPaused = !isDoneColumn && task.paused === true;
|
const isPaused = !isDoneColumn && task.paused === true;
|
||||||
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs);
|
||||||
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
|
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState });
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
|||||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||||
import { isTaskStuck } from "../utils/taskStuck";
|
import { isTaskStuck } from "../utils/taskStuck";
|
||||||
|
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery";
|
||||||
import { getRevertOfId, isTaskReverted } from "../utils/taskRevert";
|
import { getRevertOfId, isTaskReverted } from "../utils/taskRevert";
|
||||||
import { getStalledReviewSignal } from "../utils/taskStalledReview";
|
import { getStalledReviewSignal } from "../utils/taskStalledReview";
|
||||||
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy";
|
||||||
@@ -1258,15 +1259,9 @@ function TaskCardComponent({
|
|||||||
|
|
||||||
const isDoneColumn = task.column === "done";
|
const isDoneColumn = task.column === "done";
|
||||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||||
const isFailed = !isDoneColumn && task.status === "failed";
|
const hasPendingRecovery = hasPendingAutomaticRecovery(task, lastFetchTimeMs);
|
||||||
const canRetryTask =
|
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingRecovery;
|
||||||
task.status === "failed" ||
|
const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs);
|
||||||
task.status === "stuck-killed" ||
|
|
||||||
task.status === "planning" ||
|
|
||||||
task.status === "needs-replan" ||
|
|
||||||
(task.stuckKillCount ?? 0) > 0 ||
|
|
||||||
(task.recoveryRetryCount ?? 0) > 0 ||
|
|
||||||
Boolean(task.nextRecoveryAt);
|
|
||||||
const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true);
|
const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true);
|
||||||
const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId);
|
const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId);
|
||||||
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
|
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR
|
|||||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||||
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
|
import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy";
|
||||||
import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy";
|
import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy";
|
||||||
|
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery";
|
||||||
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
|
import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry";
|
||||||
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
|
import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay";
|
||||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
||||||
@@ -761,14 +762,8 @@ export function TaskDetailContent({
|
|||||||
const openPromptFile = useCallback(() => {
|
const openPromptFile = useCallback(() => {
|
||||||
fileBrowser?.openFile(`.fusion/tasks/${workingTask.id}/PROMPT.md`, { workspace: "project" });
|
fileBrowser?.openFile(`.fusion/tasks/${workingTask.id}/PROMPT.md`, { workspace: "project" });
|
||||||
}, [fileBrowser, workingTask.id]);
|
}, [fileBrowser, workingTask.id]);
|
||||||
const canRetryTask =
|
const hasPendingRecovery = hasPendingAutomaticRecovery(task);
|
||||||
task.status === "failed" ||
|
const canRetryTask = isTaskManuallyRetryable(task);
|
||||||
task.status === "stuck-killed" ||
|
|
||||||
task.status === "planning" ||
|
|
||||||
task.status === "needs-replan" ||
|
|
||||||
(task.stuckKillCount ?? 0) > 0 ||
|
|
||||||
(task.recoveryRetryCount ?? 0) > 0 ||
|
|
||||||
Boolean(task.nextRecoveryAt);
|
|
||||||
const nearDuplicateOf = isStringValue(workingTask.sourceMetadata?.nearDuplicateOf)
|
const nearDuplicateOf = isStringValue(workingTask.sourceMetadata?.nearDuplicateOf)
|
||||||
? workingTask.sourceMetadata.nearDuplicateOf
|
? workingTask.sourceMetadata.nearDuplicateOf
|
||||||
: null;
|
: null;
|
||||||
@@ -3390,7 +3385,7 @@ export function TaskDetailContent({
|
|||||||
independently of the Raw Logs segment because FN-7995 persists bounded `tool_error`
|
independently of the Raw Logs segment because FN-7995 persists bounded `tool_error`
|
||||||
detail there; the Raw-Logs-gated display list is not a diagnostic data source.
|
detail there; the Raw-Logs-gated display list is not a diagnostic data source.
|
||||||
*/
|
*/
|
||||||
const shouldShowTaskFailureAlert = Boolean(task.status === "failed" && !isPlannerChatExpanded);
|
const shouldShowTaskFailureAlert = Boolean(task.status === "failed" && !hasPendingRecovery && !isPlannerChatExpanded);
|
||||||
const taskFailureReason = task.error?.trim() || t("taskDetail.error.genericFailureReason", "The task failed before it could complete.");
|
const taskFailureReason = task.error?.trim() || t("taskDetail.error.genericFailureReason", "The task failed before it could complete.");
|
||||||
const taskFailureToolDetail = useMemo(() => {
|
const taskFailureToolDetail = useMemo(() => {
|
||||||
const lastToolError = [...agentLogEntries].reverse().find((entry) => entry.type === "tool_error" && entry.detail?.trim());
|
const lastToolError = [...agentLogEntries].reverse().find((entry) => entry.type === "tool_error" && entry.detail?.trim());
|
||||||
|
|||||||
@@ -1972,6 +1972,44 @@ describe("ListView", () => {
|
|||||||
expect(statusBadge.className).toContain("failed");
|
expect(statusBadge.className).toContain("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses failed table styling and Retry for a stale failed task with automatic recovery pending", () => {
|
||||||
|
const viewportSpy = mockDesktopViewport();
|
||||||
|
const task = createMockTask({
|
||||||
|
id: "FN-RECOVERY",
|
||||||
|
status: "failed",
|
||||||
|
column: "todo",
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderListView({ tasks: [task] });
|
||||||
|
|
||||||
|
const row = document.querySelector('.list-row[data-id="FN-RECOVERY"]') as HTMLElement;
|
||||||
|
expect(row).not.toHaveClass("failed");
|
||||||
|
expect(screen.getByText("failed")).not.toHaveClass("failed");
|
||||||
|
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses failed card styling on the mobile ListView path while automatic recovery is pending", () => {
|
||||||
|
const viewportSpy = mockMobileViewport();
|
||||||
|
const task = createMockTask({
|
||||||
|
id: "FN-RECOVERY-MOBILE",
|
||||||
|
status: "failed",
|
||||||
|
column: "todo",
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderListView({ tasks: [task] });
|
||||||
|
|
||||||
|
const card = document.querySelector('.list-card[data-id="FN-RECOVERY-MOBILE"]') as HTMLElement;
|
||||||
|
expect(card).toBeTruthy();
|
||||||
|
expect(screen.getByText("failed")).not.toHaveClass("failed");
|
||||||
|
viewportSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: "failed + in-review uses error token color",
|
name: "failed + in-review uses error token color",
|
||||||
|
|||||||
@@ -3246,6 +3246,28 @@ describe("TaskCard", () => {
|
|||||||
expect(container.querySelector(".card-error")).toBeNull();
|
expect(container.querySelector(".card-error")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses failed chrome and Retry while a stale failed task has automatic recovery pending", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TaskCard
|
||||||
|
task={makeTask({
|
||||||
|
column: "todo",
|
||||||
|
status: "failed",
|
||||||
|
error: "Transient provider error",
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
})}
|
||||||
|
onOpenDetail={noop}
|
||||||
|
addToast={noop}
|
||||||
|
onRetryTask={vi.fn(async () => ({}) as Task)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector(".card-error")).toBeNull();
|
||||||
|
expect(container.querySelector(".card.failed")).toBeNull();
|
||||||
|
expect(container.querySelector(".card-status-badge.failed")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Retry" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onRetryTask with task id", async () => {
|
it("calls onRetryTask with task id", async () => {
|
||||||
const onRetryTask = vi.fn(async () => ({}) as Task);
|
const onRetryTask = vi.fn(async () => ({}) as Task);
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -1589,6 +1589,32 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.queryByText("Retry")).toBeNull();
|
expect(screen.queryByText("Retry")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses failure alert and Retry actions while a stale failed task has automatic recovery pending", () => {
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
initialTab="definition"
|
||||||
|
task={makeTask({
|
||||||
|
status: "failed",
|
||||||
|
error: "Transient provider error",
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
})}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
onRetryTask={noopRetry}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Retry" })).toBeNull();
|
||||||
|
expect(screen.queryByText("Retry with a different model/node")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
describe("retry action uniqueness for in-review failed tasks", () => {
|
describe("retry action uniqueness for in-review failed tasks", () => {
|
||||||
it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => {
|
it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => {
|
||||||
render(
|
render(
|
||||||
|
|||||||
77
packages/dashboard/app/utils/__tests__/taskRecovery.test.ts
Normal file
77
packages/dashboard/app/utils/__tests__/taskRecovery.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../taskRecovery";
|
||||||
|
|
||||||
|
const nowMs = Date.parse("2026-07-16T12:00:00.000Z");
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: "FN-8167",
|
||||||
|
title: "Recovery fixture",
|
||||||
|
description: "",
|
||||||
|
column: "todo",
|
||||||
|
steps: [],
|
||||||
|
dependencies: [],
|
||||||
|
status: undefined,
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("task recovery presentation", () => {
|
||||||
|
it("recognizes only finite strictly-future recovery schedules as pending", () => {
|
||||||
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: new Date(nowMs + 1).toISOString() }), nowMs)).toBe(true);
|
||||||
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: new Date(nowMs).toISOString() }), nowMs)).toBe(false);
|
||||||
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: new Date(nowMs - 1).toISOString() }), nowMs)).toBe(false);
|
||||||
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: "not-a-date" }), nowMs)).toBe(false);
|
||||||
|
expect(hasPendingAutomaticRecovery(makeTask({ nextRecoveryAt: null }), nowMs)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses manual retry for future automatic recovery in active columns", () => {
|
||||||
|
for (const column of ["todo", "in-progress"] as const) {
|
||||||
|
const task = makeTask({
|
||||||
|
column,
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(nowMs + 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
expect(hasPendingAutomaticRecovery(task, nowMs), column).toBe(true);
|
||||||
|
expect(isTaskManuallyRetryable(task, nowMs), column).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a future schedule win over a defensive stale failed status", () => {
|
||||||
|
const task = makeTask({
|
||||||
|
status: "failed",
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(nowMs + 60_000).toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(hasPendingAutomaticRecovery(task, nowMs)).toBe(true);
|
||||||
|
expect(isTaskManuallyRetryable(task, nowMs)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to terminal retry states after recovery is elapsed or unscheduled", () => {
|
||||||
|
expect(isTaskManuallyRetryable(makeTask({
|
||||||
|
status: "failed",
|
||||||
|
recoveryRetryCount: 1,
|
||||||
|
nextRecoveryAt: new Date(nowMs - 60_000).toISOString(),
|
||||||
|
}), nowMs)).toBe(true);
|
||||||
|
expect(isTaskManuallyRetryable(makeTask({ status: "failed", recoveryRetryCount: 1 }), nowMs)).toBe(true);
|
||||||
|
expect(isTaskManuallyRetryable(makeTask({ status: "failed", recoveryRetryCount: null, nextRecoveryAt: null }), nowMs)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps established terminal retry states retryable without a pending schedule", () => {
|
||||||
|
for (const task of [
|
||||||
|
makeTask({ status: "stuck-killed" }),
|
||||||
|
makeTask({ status: "needs-replan" }),
|
||||||
|
makeTask({ status: "planning" }),
|
||||||
|
makeTask({ stuckKillCount: 1 }),
|
||||||
|
]) {
|
||||||
|
expect(isTaskManuallyRetryable(task, nowMs)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never infers retryability for done or archived tasks without terminal state", () => {
|
||||||
|
expect(isTaskManuallyRetryable(makeTask({ column: "done" }), nowMs)).toBe(false);
|
||||||
|
expect(isTaskManuallyRetryable(makeTask({ column: "archived" }), nowMs)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/dashboard/app/utils/taskRecovery.ts
Normal file
32
packages/dashboard/app/utils/taskRecovery.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Task } from "@fusion/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:TaskRecoveryAffordance 2026-07-16-12:00:
|
||||||
|
* FN-8167 treats a finite, strictly-future automatic recovery schedule as non-terminal.
|
||||||
|
* It wins over a stale `failed` status, so failed chrome and manual Retry render only
|
||||||
|
* after automatic recovery is no longer pending.
|
||||||
|
*/
|
||||||
|
export function hasPendingAutomaticRecovery(task: Task, nowMs = Date.now()): boolean {
|
||||||
|
const recoveryAtMs = Date.parse(task.nextRecoveryAt ?? "");
|
||||||
|
return Number.isFinite(recoveryAtMs) && recoveryAtMs > nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a task needs a human-initiated retry.
|
||||||
|
*
|
||||||
|
* FNXC:TaskRecoveryAffordance 2026-07-16-12:00:
|
||||||
|
* A nonzero `recoveryRetryCount` and elapsed `nextRecoveryAt` do not themselves make a
|
||||||
|
* task retryable: elapsed or absent schedules fall back to terminal-status rules. A
|
||||||
|
* strictly-future schedule suppresses manual retry regardless of status.
|
||||||
|
*/
|
||||||
|
export function isTaskManuallyRetryable(task: Task, nowMs = Date.now()): boolean {
|
||||||
|
if (hasPendingAutomaticRecovery(task, nowMs)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return task.status === "failed"
|
||||||
|
|| task.status === "stuck-killed"
|
||||||
|
|| task.status === "planning"
|
||||||
|
|| task.status === "needs-replan"
|
||||||
|
|| (task.stuckKillCount ?? 0) > 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user