From 50179ed5eb8088b0e6049a078822dc9e8a0dcee6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 20:25:51 -0700 Subject: [PATCH] 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) --- .../fn-8167-transient-retry-affordance.md | 7 ++ .../dashboard/app/components/ListView.tsx | 16 ++-- .../dashboard/app/components/TaskCard.tsx | 13 +--- .../app/components/TaskDetailModal.tsx | 13 +--- .../components/__tests__/ListView.test.tsx | 38 +++++++++ .../components/__tests__/TaskCard.test.tsx | 22 ++++++ .../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(-) create mode 100644 .changeset/fn-8167-transient-retry-affordance.md create mode 100644 packages/dashboard/app/utils/__tests__/taskRecovery.test.ts create mode 100644 packages/dashboard/app/utils/taskRecovery.ts diff --git a/.changeset/fn-8167-transient-retry-affordance.md b/.changeset/fn-8167-transient-retry-affordance.md new file mode 100644 index 0000000000..50822fcdca --- /dev/null +++ b/.changeset/fn-8167-transient-retry-affordance.md @@ -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. diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 2a4f5d6104..7b912a49c7 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -17,6 +17,7 @@ import { QuickEntryBox } from "./QuickEntryBox"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { NodeHealthDot } from "./NodeHealthDot"; import { isTaskStuck } from "../utils/taskStuck"; +import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery"; import type { ToastType } from "../hooks/useToast"; import { useViewportMode } from "../hooks/useViewportMode"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; @@ -1704,14 +1705,7 @@ export function ListView({ }, [addToast, onTasksUpdated, t]); const buildListContextMenuActions = useCallback((task: Task): TaskMenuActionDescriptor[] => { - const canRetryTask = - 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 canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs); const isTaskPaused = Boolean(task.paused || task.userPaused); const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMerge ?? false }); 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 }); } 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( () => (contextMenuState ? buildListContextMenuActions(contextMenuState.task) : []), @@ -2671,7 +2665,7 @@ export function ListView({ columnTasks.map((task) => { const isDoneColumn = isCompleteColumn(task.column); 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 isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState }); @@ -2884,7 +2878,7 @@ export function ListView({ columnTasks.map((task) => { const isDoneColumn = isCompleteColumn(task.column); 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 isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs); const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState }); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 5f43cdb759..ff4f37e150 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -33,6 +33,7 @@ import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { useAgentsMapCache } from "../hooks/useAgentsMapCache"; import { isTaskStuck } from "../utils/taskStuck"; +import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery"; import { getRevertOfId, isTaskReverted } from "../utils/taskRevert"; import { getStalledReviewSignal } from "../utils/taskStalledReview"; import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inReviewStallCopy"; @@ -1258,15 +1259,9 @@ function TaskCardComponent({ const isDoneColumn = task.column === "done"; const visualStatus = isDoneColumn ? "done" : task.status; - const isFailed = !isDoneColumn && task.status === "failed"; - const canRetryTask = - 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 hasPendingRecovery = hasPendingAutomaticRecovery(task, lastFetchTimeMs); + const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingRecovery; + const canRetryTask = isTaskManuallyRetryable(task, lastFetchTimeMs); const isPaused = !isDoneColumn && (task.paused === true || task.userPaused === true); const pausedByAgent = Boolean(!isDoneColumn && task.paused && task.pausedByAgentId); const normalizedPriority = normalizeTaskPriorityValue(task.priority); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index ea59a64531..7bf9299659 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -69,6 +69,7 @@ import { getInReviewStallCopy, shouldShowInReviewStallBadge } from "../utils/inR import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { getStalePausedReviewCopy, shouldShowStalePausedReviewBadge } from "../utils/stalePausedReviewCopy"; import { getTaskAgeStalenessCopy } from "../utils/taskAgeStalenessCopy"; +import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery"; import { findInReviewStallLogEntry, IN_REVIEW_STALL_LOG_REGEX } from "../utils/findInReviewStallLogEntry"; import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; @@ -761,14 +762,8 @@ export function TaskDetailContent({ const openPromptFile = useCallback(() => { fileBrowser?.openFile(`.fusion/tasks/${workingTask.id}/PROMPT.md`, { workspace: "project" }); }, [fileBrowser, workingTask.id]); - const canRetryTask = - 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 hasPendingRecovery = hasPendingAutomaticRecovery(task); + const canRetryTask = isTaskManuallyRetryable(task); const nearDuplicateOf = isStringValue(workingTask.sourceMetadata?.nearDuplicateOf) ? workingTask.sourceMetadata.nearDuplicateOf : null; @@ -3390,7 +3385,7 @@ export function TaskDetailContent({ 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. */ - 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 taskFailureToolDetail = useMemo(() => { const lastToolError = [...agentLogEntries].reverse().find((entry) => entry.type === "tool_error" && entry.detail?.trim()); diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index d4a2d31938..bc8c675ff3 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -1972,6 +1972,44 @@ describe("ListView", () => { 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([ { name: "failed + in-review uses error token color", diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index 759925d763..769a110b99 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -3246,6 +3246,28 @@ describe("TaskCard", () => { expect(container.querySelector(".card-error")).toBeNull(); }); + it("suppresses failed chrome and Retry while a stale failed task has automatic recovery pending", () => { + const { container } = render( + ({}) 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 () => { const onRetryTask = vi.fn(async () => ({}) as Task); render( diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx index 0b5cdd3998..0f8c989822 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.rendering.test.tsx @@ -1589,6 +1589,32 @@ describe("TaskDetailModal", () => { expect(screen.queryByText("Retry")).toBeNull(); }); + it("suppresses failure alert and Retry actions while a stale failed task has automatic recovery pending", () => { + render( + , + ); + + 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", () => { it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => { render( diff --git a/packages/dashboard/app/utils/__tests__/taskRecovery.test.ts b/packages/dashboard/app/utils/__tests__/taskRecovery.test.ts new file mode 100644 index 0000000000..07b1748217 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/taskRecovery.test.ts @@ -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 { + 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); + }); +}); diff --git a/packages/dashboard/app/utils/taskRecovery.ts b/packages/dashboard/app/utils/taskRecovery.ts new file mode 100644 index 0000000000..c55c655108 --- /dev/null +++ b/packages/dashboard/app/utils/taskRecovery.ts @@ -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; +}