From 96b1f21707442afff7b071fbd9562dadbf165ac9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 16:35:26 -0700 Subject: [PATCH] FN-7999: show failed-banner diagnostics and model/node retry Expose a richer Task Failed banner with tool-error diagnostics and one-click retry using a different model or node. - Always show the failed banner for failed tasks (including errorless failures) with a generic reason fallback - Surface the latest agent-log tool_error detail and a retry hint for workflow/step-execute failures - Add Retry and Retry with a different model/node actions with deferred model/node override save on confirm - Style the banner recovery controls and cover them in TaskDetailModal tests - Add minor changeset for @runfusion/fusion Files changed: .changeset/fn-7999-failed-banner-retry.md | 7 + .../dashboard/app/components/TaskDetailModal.css | 47 +++++++ .../dashboard/app/components/TaskDetailModal.tsx | 143 ++++++++++++++++++++- .../__tests__/TaskDetailModal.test-helpers.ts | 3 +- .../components/__tests__/TaskDetailModal.test.tsx | 59 ++++++++- 5 files changed, 248 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-7999 Fusion-Task-Lineage: d4268c43-442f-4f39-afbe-c6f583ec0fc0 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7999-failed-banner-retry.md | 7 + .../app/components/TaskDetailModal.css | 47 ++++++ .../app/components/TaskDetailModal.tsx | 143 +++++++++++++++++- .../__tests__/TaskDetailModal.test-helpers.ts | 3 +- .../__tests__/TaskDetailModal.test.tsx | 59 +++++++- 5 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-7999-failed-banner-retry.md diff --git a/.changeset/fn-7999-failed-banner-retry.md b/.changeset/fn-7999-failed-banner-retry.md new file mode 100644 index 0000000000..8acf5f2ffa --- /dev/null +++ b/.changeset/fn-7999-failed-banner-retry.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a diagnostic summary and one-click "Retry with a different model/node" to the Task Failed banner. +category: feature +dev: TaskDetailModal now renders the banner for all failed tasks (including errorless), surfaces the latest tool_error detail (FN-7995), and applies model/node overrides via updateTask before re-running the existing retry path. diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index d2dfe8ad91..8027eabb4b 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -1062,6 +1062,53 @@ for a "skipped" outcome upstream). word-break: break-word; } +.detail-error-detail, +.detail-error-hint { + margin-top: var(--space-sm); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.detail-error-detail { + color: var(--text); +} + +.detail-error-hint { + color: var(--text-muted); +} + +.detail-error-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm); + margin-top: var(--space-md); +} + +.detail-error-retry-picker { + display: grid; + gap: var(--space-sm); + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--border); + color: var(--text); + font-size: 12px; +} + +.detail-error-retry-picker .select { + max-width: 100%; +} + +@media (max-width: 768px) { + .detail-error-actions { + align-items: stretch; + } + + .detail-error-actions .btn { + flex: 1 1 auto; + } +} + .task-pause-reason { margin: var(--space-md) 0 var(--space-lg); padding: var(--space-md); diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 6e6b81dff5..7929c42073 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -23,8 +23,8 @@ import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflo import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical"; import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert"; import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; -import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, api } from "../api"; -import type { RevertTaskOptions, RevertTaskResult } from "../api"; +import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, fetchWorkflowSettingValues, nudgeOverseer, stopOverseer, explainOverseer, fetchModels, fetchNodes, api } from "../api"; +import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api"; import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import { WorkflowIcon } from "./WorkflowIcon"; import { ApiRequestError } from "../api"; @@ -1030,6 +1030,12 @@ export function TaskDetailContent({ // Edit mode state const [isEditing, setIsEditing] = useState(false); + const [showFailureRetryPicker, setShowFailureRetryPicker] = useState(false); + const [failureRetryModels, setFailureRetryModels] = useState([]); + const [failureRetryNodes, setFailureRetryNodes] = useState([]); + const [failureRetryModel, setFailureRetryModel] = useState(""); + const [failureRetryNodeId, setFailureRetryNodeId] = useState(""); + const [isFailureRetrySaving, setIsFailureRetrySaving] = useState(false); useEffect(() => { if (activeTab !== "chat" || isEditing) { @@ -2307,7 +2313,7 @@ export function TaskDetailContent({ loadingMore: agentLogLoadingMore, } = useAgentLogs( task.id, - activeTab === "chat" && activitySegment === "raw-logs", + task.status === "failed" || (activeTab === "chat" && activitySegment === "raw-logs"), projectId, ); useEffect(() => { @@ -2591,7 +2597,50 @@ export function TaskDetailContent({ .catch((err) => { addToast(getErrorMessage(err), "error"); }); - }, [task.id, onRetryTask, requestClose, addToast]); + }, [task.id, onRetryTask, requestClose, addToast, t]); + + useEffect(() => { + if (!showFailureRetryPicker) return; + setFailureRetryModel(task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : ""); + setFailureRetryNodeId(task.nodeId ?? ""); + void Promise.all([fetchModels(), fetchNodes()]) + .then(([models, nodes]) => { + setFailureRetryModels(models.models); + setFailureRetryNodes(nodes); + }) + .catch((err) => addToast(getErrorMessage(err) || t("taskDetail.error.retryOptionsFailed", "Failed to load retry options"), "error")); + }, [addToast, showFailureRetryPicker, t, task.id, task.modelId, task.modelProvider, task.nodeId]); + + /* + FNXC:TaskFailedBanner 2026-07-15-16:30: + The failed-banner picker stages model/node choices and writes one per-task override + only when the operator confirms Retry. RoutingTab saves on selection, which would + leave an abandoned override when the operator closes this recovery picker. + */ + const handleRetryWithOverride = useCallback(async () => { + if (!onRetryTask || isFailureRetrySaving) return; + const modelSelection = splitModelSelection(failureRetryModel); + const currentModel = task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : ""; + const hasModelChange = failureRetryModel !== currentModel; + const hasNodeChange = failureRetryNodeId !== (task.nodeId ?? ""); + if (!hasModelChange && !hasNodeChange) return; + + setIsFailureRetrySaving(true); + try { + const updatedTask = await updateTask(task.id, { + ...(hasModelChange ? { modelProvider: modelSelection?.provider ?? null, modelId: modelSelection?.modelId ?? null } : {}), + ...(hasNodeChange ? { nodeId: failureRetryNodeId || null } : {}), + }, projectId); + onTaskUpdated?.(updatedTask); + await onRetryTask(task.id); + addToast(t("taskDetail.retry.retried", "Retried {{id}}", { id: task.id }), "success"); + requestClose(); + } catch (err) { + addToast(getErrorMessage(err), "error"); + } finally { + if (mountedRef.current) setIsFailureRetrySaving(false); + } + }, [addToast, failureRetryModel, failureRetryNodeId, isFailureRetrySaving, onRetryTask, onTaskUpdated, projectId, requestClose, t, task.id, task.modelId, task.modelProvider, task.nodeId]); /* FNXC:ReviewLaneBypass 2026-07-09-00:00: @@ -3328,7 +3377,22 @@ export function TaskDetailContent({ FNXC:TaskDetailPlannerChat 2026-07-01-00:00: Maximized Planner Chat reserves vertical room for task identity and the planner conversation, so failed-task chrome is not mounted in that state. Normal detail, Activity expansion, and collapsed Planner Chat still surface task failures immediately. */ - const shouldShowTaskFailureAlert = Boolean(task.status === "failed" && task.error && !isPlannerChatExpanded); + /* + FNXC:TaskFailedBanner 2026-07-15-16:30: + Failed tasks must always expose recovery controls, including legacy/errorless failures, + without mounting an empty error-message shell. The default banner fetches agent logs + 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 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()); + return lastToolError?.detail?.trim().slice(0, 1024); + }, [agentLogEntries]); + const taskFailureHint = /workflow graph terminated|step-execute|no files? (were )?modified/i.test(`${task.error ?? ""}\n${taskFailureToolDetail ?? ""}`) + ? t("taskDetail.error.retryHint", "Consider retrying with a different model or node.") + : null; const taskActionMenuModel = useMemo(() => buildTaskActionMenuModel({ task, @@ -4492,11 +4556,76 @@ export function TaskDetailContent({ )} {shouldShowTaskFailureAlert && ( -
+
⚠
{t("taskDetail.error.taskFailed", "Task Failed")}
-
{task.error}
+
{taskFailureReason}
+ {taskFailureToolDetail ? ( +
+ {taskFailureToolDetail} +
+ ) : null} + {taskFailureHint ?
{taskFailureHint}
: null} + {onRetryTask && canRetryTask ? ( +
+ + +
+ ) : null} + {showFailureRetryPicker && onRetryTask && canRetryTask ? ( +
+ + + + +
+ + +
+
+ ) : null}
)} diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts index 6eb6b20d2c..fc3a9e99b5 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts @@ -43,7 +43,8 @@ vi.mock("../../api", async (importOriginal) => { assignTask: vi.fn().mockResolvedValue({}), fetchAgents: vi.fn().mockResolvedValue([]), fetchAgent: vi.fn().mockResolvedValue(null), - fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }), + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), + fetchNodes: vi.fn().mockResolvedValue([]), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchGlobalSettings: vi.fn().mockResolvedValue({}), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx index 906caea506..e6215be123 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.test.tsx @@ -296,7 +296,8 @@ describe("TaskDetailModal planner Chat tab", () => { expect(container.querySelector(".detail-error-alert")).toBeInTheDocument(); }); - it("does not render empty failed-task alert shells for non-failed or errorless failed tasks", () => { + it("renders an actionable generic failed-task alert without an empty message shell", async () => { + const onRetryTask = vi.fn().mockResolvedValue(makeTask()); const { container, rerender } = render( { onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} + onRetryTask={onRetryTask} addToast={noop} />, ); - expect(screen.queryByText("Task Failed")).not.toBeInTheDocument(); - expect(container.querySelector(".detail-error-alert")).toBeNull(); + expect(screen.getByText("Task Failed")).toBeInTheDocument(); + expect(screen.getByText("The task failed before it could complete.")).toBeInTheDocument(); + expect(container.querySelector(".detail-error-message")?.textContent).not.toBe(""); + await userEvent.setup().click(screen.getByRole("button", { name: "Retry" })); + expect(onRetryTask).toHaveBeenCalledWith("FN-099"); rerender( { expect(screen.queryByText("Task Failed")).not.toBeInTheDocument(); expect(container.querySelector(".detail-error-alert")).toBeNull(); }); + + it("surfaces the latest tool error detail and stages a model override before retrying", async () => { + const user = userEvent.setup(); + const { useAgentLogs } = await import("../../hooks/useAgentLogs"); + const { fetchModels, fetchNodes, updateTask } = await import("../../api"); + vi.mocked(useAgentLogs).mockReturnValue({ + entries: [ + { timestamp: "2026-07-15T16:00:00Z", taskId: "FN-099", text: "older tool", type: "tool_error", detail: "Older failure" }, + { timestamp: "2026-07-15T16:01:00Z", taskId: "FN-099", text: "write", type: "tool_error", detail: "Permission denied while writing the requested file" }, + ], + loading: false, + clear: vi.fn(), + loadMore: vi.fn(async () => {}), + hasMore: false, + total: 2, + loadingMore: false, + }); + vi.mocked(fetchModels).mockResolvedValue({ models: [{ provider: "anthropic", id: "claude-alternate", name: "Claude Alternate", reasoning: true, contextWindow: 200000 }], favoriteProviders: [], favoriteModels: [] }); + vi.mocked(fetchNodes).mockResolvedValue([{ id: "node-alternate", name: "Alternate node", type: "remote", status: "online", maxConcurrent: 1, createdAt: "", updatedAt: "" }]); + vi.mocked(updateTask).mockResolvedValue(makeTask({ modelProvider: "anthropic", modelId: "claude-alternate" })); + const onRetryTask = vi.fn().mockResolvedValue(makeTask()); + + render( + , + ); + + expect(screen.getByText("Permission denied while writing the requested file")).toBeInTheDocument(); + expect(screen.getByText("Consider retrying with a different model or node.")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry with a different model/node" })); + await screen.findByLabelText("Executor model"); + await user.selectOptions(screen.getByLabelText("Executor model"), "anthropic/claude-alternate"); + await user.click(screen.getByRole("button", { name: "Apply and retry" })); + + await waitFor(() => expect(updateTask).toHaveBeenCalledWith("FN-099", { modelProvider: "anthropic", modelId: "claude-alternate" }, undefined)); + await waitFor(() => expect(onRetryTask).toHaveBeenCalledWith("FN-099")); + vi.mocked(useAgentLogs).mockReturnValue({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false }); + }); }); describe("TaskDetailModal summarize title action", () => {