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:
7
.changeset/fn-7999-failed-banner-retry.md
Normal file
7
.changeset/fn-7999-failed-banner-retry.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user