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; 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 { .task-pause-reason {
margin: var(--space-md) 0 var(--space-lg); margin: var(--space-md) 0 var(--space-lg);
padding: var(--space-md); 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 { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert"; import { getRevertOfId, findOpenUndoTaskForSource } from "../utils/taskRevert";
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge"; 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 { 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 } from "../api"; import type { RevertTaskOptions, RevertTaskResult, ModelInfo, NodeInfo } from "../api";
import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api"; import type { BoardWorkflowsPayload, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { WorkflowIcon } from "./WorkflowIcon"; import { WorkflowIcon } from "./WorkflowIcon";
import { ApiRequestError } from "../api"; import { ApiRequestError } from "../api";
@@ -1030,6 +1030,12 @@ export function TaskDetailContent({
// Edit mode state // Edit mode state
const [isEditing, setIsEditing] = useState(false); 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(() => { useEffect(() => {
if (activeTab !== "chat" || isEditing) { if (activeTab !== "chat" || isEditing) {
@@ -2307,7 +2313,7 @@ export function TaskDetailContent({
loadingMore: agentLogLoadingMore, loadingMore: agentLogLoadingMore,
} = useAgentLogs( } = useAgentLogs(
task.id, task.id,
activeTab === "chat" && activitySegment === "raw-logs", task.status === "failed" || (activeTab === "chat" && activitySegment === "raw-logs"),
projectId, projectId,
); );
useEffect(() => { useEffect(() => {
@@ -2591,7 +2597,50 @@ export function TaskDetailContent({
.catch((err) => { .catch((err) => {
addToast(getErrorMessage(err), "error"); 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: FNXC:ReviewLaneBypass 2026-07-09-00:00:
@@ -3328,7 +3377,22 @@ export function TaskDetailContent({
FNXC:TaskDetailPlannerChat 2026-07-01-00:00: 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. 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({ const taskActionMenuModel = useMemo(() => buildTaskActionMenuModel({
task, task,
@@ -4492,11 +4556,76 @@ export function TaskDetailContent({
</> </>
)} )}
{shouldShowTaskFailureAlert && ( {shouldShowTaskFailureAlert && (
<div className="detail-error-alert"> <div className="detail-error-alert" role="alert">
<span className="detail-error-icon">⚠</span> <span className="detail-error-icon">⚠</span>
<div className="detail-error-content"> <div className="detail-error-content">
<div className="detail-error-title">{t("taskDetail.error.taskFailed", "Task Failed")}</div> <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>
</div> </div>
)} )}

View File

@@ -43,7 +43,8 @@ vi.mock("../../api", async (importOriginal) => {
assignTask: vi.fn().mockResolvedValue({}), assignTask: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([]), fetchAgents: vi.fn().mockResolvedValue([]),
fetchAgent: vi.fn().mockResolvedValue(null), 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: {} }), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({}), fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchWorkflowSteps: 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(); 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( const { container, rerender } = render(
<TaskDetailModal <TaskDetailModal
initialTab="planner-chat" initialTab="planner-chat"
@@ -307,12 +308,16 @@ describe("TaskDetailModal planner Chat tab", () => {
onDeleteTask={noopDelete} onDeleteTask={noopDelete}
onMergeTask={noopMerge} onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail} onOpenDetail={noopOpenDetail}
onRetryTask={onRetryTask}
addToast={noop} addToast={noop}
/>, />,
); );
expect(screen.queryByText("Task Failed")).not.toBeInTheDocument(); expect(screen.getByText("Task Failed")).toBeInTheDocument();
expect(container.querySelector(".detail-error-alert")).toBeNull(); 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( rerender(
<TaskDetailModal <TaskDetailModal
@@ -331,6 +336,54 @@ describe("TaskDetailModal planner Chat tab", () => {
expect(screen.queryByText("Task Failed")).not.toBeInTheDocument(); expect(screen.queryByText("Task Failed")).not.toBeInTheDocument();
expect(container.querySelector(".detail-error-alert")).toBeNull(); 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", () => { describe("TaskDetailModal summarize title action", () => {