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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 16:35:26 -07:00
parent 0e84731d8a
commit 96b1f21707
5 changed files with 248 additions and 11 deletions

View File

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

View File

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

View File

@@ -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<ModelInfo[]>([]);
const [failureRetryNodes, setFailureRetryNodes] = useState<NodeInfo[]>([]);
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 && (
<div className="detail-error-alert">
<div className="detail-error-alert" role="alert">
<span className="detail-error-icon">⚠</span>
<div className="detail-error-content">
<div className="detail-error-title">{t("taskDetail.error.taskFailed", "Task Failed")}</div>
<div className="detail-error-message">{task.error}</div>
<div className="detail-error-message">{taskFailureReason}</div>
{taskFailureToolDetail ? (
<div className="detail-error-detail">
{taskFailureToolDetail}
</div>
) : null}
{taskFailureHint ? <div className="detail-error-hint">{taskFailureHint}</div> : null}
{onRetryTask && canRetryTask ? (
<div className="detail-error-actions">
<button type="button" className="btn btn-sm" onClick={handleRetry}>
{t("taskDetail.error.retry", "Retry")}
</button>
<button type="button" className="btn btn-sm" onClick={() => setShowFailureRetryPicker(true)}>
{t("taskDetail.error.retryWithModel", "Retry with a different model/node")}
</button>
</div>
) : null}
{showFailureRetryPicker && onRetryTask && canRetryTask ? (
<div className="detail-error-retry-picker">
<label htmlFor={`failure-retry-model-${task.id}`}>
{t("taskDetail.error.retryModelLabel", "Executor model")}
</label>
<select
id={`failure-retry-model-${task.id}`}
className="select"
value={failureRetryModel}
disabled={isFailureRetrySaving}
onChange={(event) => setFailureRetryModel(event.target.value)}
>
<option value="">{t("taskDetail.error.retryModelDefault", "Use project default")}</option>
{failureRetryModels.map((model) => (
<option key={`${model.provider}/${model.id}`} value={`${model.provider}/${model.id}`}>
{model.provider}/{model.name || model.id}
</option>
))}
</select>
<label htmlFor={`failure-retry-node-${task.id}`}>
{t("taskDetail.error.retryNodeLabel", "Execution node")}
</label>
<select
id={`failure-retry-node-${task.id}`}
className="select"
value={failureRetryNodeId}
disabled={isFailureRetrySaving}
onChange={(event) => setFailureRetryNodeId(event.target.value)}
>
<option value="">{t("taskDetail.error.retryNodeDefault", "Use project default")}</option>
{failureRetryNodes.map((node) => (
<option key={node.id} value={node.id}>{node.name} ({node.type})</option>
))}
</select>
<div className="detail-error-actions">
<button type="button" className="btn btn-sm" onClick={() => setShowFailureRetryPicker(false)} disabled={isFailureRetrySaving}>
{t("common.cancel", "Cancel")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleRetryWithOverride()}
disabled={isFailureRetrySaving || (failureRetryModel === (task.modelProvider && task.modelId ? `${task.modelProvider}/${task.modelId}` : "") && failureRetryNodeId === (task.nodeId ?? ""))}
>
{t("taskDetail.error.confirmRetry", "Apply and retry")}
</button>
</div>
</div>
) : null}
</div>
</div>
)}

View File

@@ -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([]),

View File

@@ -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(
<TaskDetailModal
initialTab="planner-chat"
@@ -307,12 +308,16 @@ describe("TaskDetailModal planner Chat tab", () => {
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(
<TaskDetailModal
@@ -331,6 +336,54 @@ describe("TaskDetailModal planner Chat tab", () => {
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(
<TaskDetailModal
initialTab="planner-chat"
taskDetailChatFirst
task={makeTask({ column: "todo" as any, status: "failed", error: "Workflow graph terminated with failure at node 'steps#0:step-execute'" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={noop}
/>,
);
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", () => {