From 0bed997af8587398a981ecab3e9be154406ed2a8 Mon Sep 17 00:00:00 2001 From: Fusion Date: Wed, 8 Jul 2026 03:09:29 -0400 Subject: [PATCH] feat: surface runtime-resolution fallback in dashboard, thread real FallbackReason Fixes silent runtime fallback visibility (dashboard never read wasConfigured or session:runtime-resolved) and threads the real FallbackReason (not_found vs factory_error) through resolveRuntime()/logRuntimeFallback instead of hardcoding "not_found" for every fallback. - packages/engine/src/runtime-resolution.ts: resolvePluginRuntime() now returns a tagged miss result distinguishing not_found from factory_error; resolveRuntime() threads the real reason through and returns it as ResolvedRuntime.fallbackReason - packages/engine/src/agent-session-helpers.ts: includes fallbackReason in the session:runtime-resolved audit event metadata - packages/dashboard/src/routes/register-task-workflow-routes.ts: new GET /api/tasks/:id/runtime-fallback endpoint - packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts + packages/dashboard/app/components/RuntimeFallbackBadge.tsx: new polling hook + badge/toast component wired into TaskCard, ActiveAgentsPanel, and AgentsView Ref: Fusion task FUX-022, investigations/FUX-017-hermes-runtime-fallback.md recommendation #1 --- packages/dashboard/app/api/legacy.ts | 23 ++ .../app/components/ActiveAgentsPanel.tsx | 4 + .../dashboard/app/components/AgentsView.tsx | 5 + .../app/components/RuntimeFallbackBadge.tsx | 57 +++++ .../dashboard/app/components/TaskCard.tsx | 2 + .../__tests__/RuntimeFallbackBadge.test.tsx | 236 ++++++++++++++++++ .../app/hooks/useRuntimeFallbackStatus.ts | 112 +++++++++ packages/dashboard/src/routes.ts | 27 ++ ...k-workflow-routes.runtime-fallback.test.ts | 137 ++++++++++ .../routes/register-task-workflow-routes.ts | 71 ++++++ .../src/__tests__/runtime-resolution.test.ts | 26 ++ packages/engine/src/agent-session-helpers.ts | 1 + packages/engine/src/runtime-resolution.ts | 40 ++- 13 files changed, 732 insertions(+), 9 deletions(-) create mode 100644 packages/dashboard/app/components/RuntimeFallbackBadge.tsx create mode 100644 packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx create mode 100644 packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts create mode 100644 packages/dashboard/src/routes/__tests__/register-task-workflow-routes.runtime-fallback.test.ts diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 92cf9f9955..05585ab7bb 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -316,6 +316,29 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${taskId}/runtime-fallback`, projectId)); +} + export interface UpdateTaskReviewRequest { reviewState: TaskDetail["reviewState"] | null; } diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.tsx b/packages/dashboard/app/components/ActiveAgentsPanel.tsx index 7abcc59532..8f769651db 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.tsx +++ b/packages/dashboard/app/components/ActiveAgentsPanel.tsx @@ -8,6 +8,7 @@ import "./ActiveAgentsPanel.css"; import { useLiveTranscript } from "../hooks/useLiveTranscript"; import { resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals"; import { AgentTaskBadge } from "./AgentTaskBadge"; +import { RuntimeFallbackBadge } from "./RuntimeFallbackBadge"; import { getCanonicalStepNumber } from "../lib/step-display"; interface LiveAgentCardProps { @@ -118,6 +119,9 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent {agent.taskId && ( )} + {agent.taskId && ( + + )}
{entries.length === 0 ? ( diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 2115bcc9e0..0feabf0f8d 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -38,6 +38,7 @@ import { import { AgentAvatar } from "./AgentAvatar"; import { AgentErrorIndicator } from "./AgentErrorDetailsModal"; import { AgentTaskBadge } from "./AgentTaskBadge"; +import { RuntimeFallbackBadge } from "./RuntimeFallbackBadge"; export interface AgentsViewProps { addToast: (message: string, type?: "success" | "error") => void; @@ -1718,6 +1719,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{agent.name}
{agent.id}
+ {agent.taskId && ( + + )}
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
@@ -1888,6 +1892,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{t("agents.workingOn", "Working on:")} +
)}
diff --git a/packages/dashboard/app/components/RuntimeFallbackBadge.tsx b/packages/dashboard/app/components/RuntimeFallbackBadge.tsx new file mode 100644 index 0000000000..8871659b29 --- /dev/null +++ b/packages/dashboard/app/components/RuntimeFallbackBadge.tsx @@ -0,0 +1,57 @@ +/** + * RuntimeFallbackBadge (FUX-022) + * + * Renders a visible badge on an agent/task card when the most recent + * `session:runtime-resolved` audit event for that task shows + * `wasConfigured: false` alongside a non-empty configured `runtimeHint` — + * i.e. the configured runtime (e.g. "hermes") could not be resolved and the + * session silently fell back to the default `pi` runtime. Also fires a toast + * via the shared ToastProvider the first time this fallback state is newly + * observed for a session (not re-fired on every poll/re-render). + * + * Renders null (no leftover placeholder) for every other state: no event + * yet, wasConfigured true, or wasConfigured false with a blank hint. + */ +import { memo, useEffect } from "react"; +import { AlertTriangle } from "lucide-react"; +import { useRuntimeFallbackStatus } from "../hooks/useRuntimeFallbackStatus"; +import { useToast } from "../hooks/useToast"; + +interface RuntimeFallbackBadgeProps { + taskId?: string; + /** Gate polling to visible cards only (e.g. pass the card's own isInViewport state). */ + isInViewport: boolean; + projectId?: string; +} + +function RuntimeFallbackBadgeComponent({ taskId, isInViewport, projectId }: RuntimeFallbackBadgeProps) { + const { addToast } = useToast(); + const status = useRuntimeFallbackStatus(taskId, isInViewport, projectId); + + useEffect(() => { + if (status.shouldToastNow && status.message) { + addToast(status.message, "warning"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- addToast identity is stable per ToastProvider instance + }, [status.shouldToastNow, status.message]); + + if (!status.showBadge || !status.message) { + return null; + } + + return ( + + + ); +} + +export const RuntimeFallbackBadge = memo(RuntimeFallbackBadgeComponent); +RuntimeFallbackBadge.displayName = "RuntimeFallbackBadge"; diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index e312111e4e..29b31a1794 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -23,6 +23,7 @@ import { resolveEffectivePlannerOversightLevel } from "../../../core/src/workflo import { addressPrFeedback, fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, rebuildTaskSpec, refreshPrStatus, fetchWorkflowSettingValues, type WorkflowFieldDefinition, type RevertTaskOptions, type RevertTaskResult } from "../api"; import { GitHubBadge } from "./GitHubBadge"; import { GitLabBadge } from "./GitLabBadge"; +import { RuntimeFallbackBadge } from "./RuntimeFallbackBadge"; import { PrCreateModal } from "./PrCreateModal"; import { ProviderIcon } from "./ProviderIcon"; import { PluginSlot } from "./PluginSlot"; @@ -2937,6 +2938,7 @@ function TaskCardComponent({ {task.gitlabTracking?.item && ( )} + {prNode && ( prNode.state === "failed" ? (