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 { 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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
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