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:
gsxdsm
2026-07-16 20:25:51 -07:00
parent f079245acc
commit 50179ed5eb
9 changed files with 215 additions and 29 deletions

View 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.

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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());

View File

@@ -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",

View File

@@ -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(
<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 () => {
const onRetryTask = vi.fn(async () => ({}) as Task);
render(

View File

@@ -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(
<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", () => {
it("shows exactly one Retry button when task is in-review AND failed (in Actions dropdown)", () => {
render(

View 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);
});
});

View 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;
}