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
This commit is contained in:
@@ -316,6 +316,29 @@ export async function fetchTaskDetail(id: string, projectId?: string): Promise<T
|
||||
throw new Error("Request failed");
|
||||
}
|
||||
|
||||
export interface TaskRuntimeFallbackResponse {
|
||||
taskId: string;
|
||||
hasEvent: boolean;
|
||||
wasConfigured: boolean | null;
|
||||
runtimeHint: string | null;
|
||||
reason: string | null;
|
||||
eventId: string | null;
|
||||
timestamp: string | null;
|
||||
showFallbackBadge: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the most recent session:runtime-resolved audit event for a task,
|
||||
* normalized for the runtime-fallback badge/toast affordance. Used by
|
||||
* useRuntimeFallbackStatus.
|
||||
*/
|
||||
export async function fetchTaskRuntimeFallback(
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
): Promise<TaskRuntimeFallbackResponse> {
|
||||
return api<TaskRuntimeFallbackResponse>(withProjectId(`/tasks/${taskId}/runtime-fallback`, projectId));
|
||||
}
|
||||
|
||||
export interface UpdateTaskReviewRequest {
|
||||
reviewState: TaskDetail["reviewState"] | null;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<span className="live-agent-task badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
)}
|
||||
{agent.taskId && (
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="live-agent-card-transcript">
|
||||
{entries.length === 0 ? (
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<div className="agent-board-name">{agent.name}</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
{agent.taskId && (
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
)}
|
||||
<div className="agent-board-health" style={{ color: health.color }} title={healthSummary.title}>
|
||||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||
</div>
|
||||
@@ -1888,6 +1892,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<div className="agent-task">
|
||||
<span className="text-secondary">{t("agents.workingOn", "Working on:")}</span>
|
||||
<span className="badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-heartbeat-control">
|
||||
|
||||
57
packages/dashboard/app/components/RuntimeFallbackBadge.tsx
Normal file
57
packages/dashboard/app/components/RuntimeFallbackBadge.tsx
Normal file
@@ -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 (
|
||||
<span
|
||||
className="card-status-badge card-runtime-fallback-badge"
|
||||
title={status.message}
|
||||
data-testid="runtime-fallback-badge"
|
||||
data-runtime-hint={status.runtimeHint ?? undefined}
|
||||
data-runtime-fallback-reason={status.reason ?? undefined}
|
||||
>
|
||||
<AlertTriangle size={10} aria-hidden="true" />
|
||||
<span>{status.message}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const RuntimeFallbackBadge = memo(RuntimeFallbackBadgeComponent);
|
||||
RuntimeFallbackBadge.displayName = "RuntimeFallbackBadge";
|
||||
@@ -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 && (
|
||||
<GitLabBadge item={task.gitlabTracking.item} />
|
||||
)}
|
||||
<RuntimeFallbackBadge taskId={task.id} isInViewport={isInViewport} projectId={projectId} />
|
||||
{prNode && (
|
||||
prNode.state === "failed" ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useRef } from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { RuntimeFallbackBadge } from "../RuntimeFallbackBadge";
|
||||
import { ToastProvider, useToast } from "../../hooks/useToast";
|
||||
import type { TaskRuntimeFallbackResponse } from "../../api/legacy";
|
||||
|
||||
const legacyMocks = vi.hoisted(() => ({
|
||||
fetchTaskRuntimeFallback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../api/legacy", () => legacyMocks);
|
||||
|
||||
// Toasts auto-dismiss after 4s (useToast's internal timer). To assert "fired
|
||||
// exactly once" across a longer window we must count every toast ever
|
||||
// appended, not just the currently-visible set, so track full history here.
|
||||
function ToastPeek() {
|
||||
const { toasts } = useToast();
|
||||
const historyRef = useRef<typeof toasts>([]);
|
||||
const seenIds = useRef(new Set<number>());
|
||||
for (const toast of toasts) {
|
||||
if (!seenIds.current.has(toast.id)) {
|
||||
seenIds.current.add(toast.id);
|
||||
historyRef.current.push(toast);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div data-testid="toast-peek">
|
||||
{historyRef.current.map((t) => (
|
||||
<div key={t.id} data-testid="toast-entry" data-type={t.type}>{t.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBadge(taskId = "FN-100", isInViewport = true) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId={taskId} isInViewport={isInViewport} projectId="proj-1" />
|
||||
<ToastPeek />
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noEvent: TaskRuntimeFallbackResponse = {
|
||||
taskId: "FN-100",
|
||||
hasEvent: false,
|
||||
wasConfigured: null,
|
||||
runtimeHint: null,
|
||||
reason: null,
|
||||
eventId: null,
|
||||
timestamp: null,
|
||||
showFallbackBadge: false,
|
||||
};
|
||||
|
||||
const configuredOk: TaskRuntimeFallbackResponse = {
|
||||
...noEvent,
|
||||
hasEvent: true,
|
||||
wasConfigured: true,
|
||||
runtimeHint: "hermes",
|
||||
eventId: "audit-ok",
|
||||
timestamp: "2026-07-08T00:00:00.000Z",
|
||||
showFallbackBadge: false,
|
||||
};
|
||||
|
||||
const fallbackBlankHint: TaskRuntimeFallbackResponse = {
|
||||
...noEvent,
|
||||
hasEvent: true,
|
||||
wasConfigured: false,
|
||||
runtimeHint: null,
|
||||
eventId: "audit-blank",
|
||||
timestamp: "2026-07-08T00:00:00.000Z",
|
||||
showFallbackBadge: false,
|
||||
};
|
||||
|
||||
const fallbackWithHint: TaskRuntimeFallbackResponse = {
|
||||
taskId: "FN-100",
|
||||
hasEvent: true,
|
||||
wasConfigured: false,
|
||||
runtimeHint: "hermes",
|
||||
reason: "not_found",
|
||||
eventId: "audit-fallback-1",
|
||||
timestamp: "2026-07-08T00:00:00.000Z",
|
||||
showFallbackBadge: true,
|
||||
};
|
||||
|
||||
describe("RuntimeFallbackBadge", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders nothing when no session:runtime-resolved event exists yet", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(noEvent);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when the most recent event has wasConfigured=true", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(configuredOk);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when wasConfigured=false but runtimeHint is blank/absent", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackBlankHint);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the badge when wasConfigured=false and runtimeHint is non-empty", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
const badge = screen.getByTestId("runtime-fallback-badge");
|
||||
expect(badge.textContent).toContain("hermes");
|
||||
expect(badge.textContent).toContain("unavailable");
|
||||
expect(badge.getAttribute("data-runtime-hint")).toBe("hermes");
|
||||
expect(badge.getAttribute("data-runtime-fallback-reason")).toBe("not_found");
|
||||
});
|
||||
|
||||
it("does not resurrect the badge when a stale wasConfigured=false event is superseded by a newer success", async () => {
|
||||
// Simulates: latest-event endpoint already reflects only the newest event,
|
||||
// so a superseded older fallback never reaches the component as `showFallbackBadge`.
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(configuredOk);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires a toast exactly once for a newly-observed fallback session, not on every poll", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
|
||||
expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes");
|
||||
|
||||
// Advance past several poll intervals with the *same* event still being the latest;
|
||||
// the toast must not fire again.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not poll (and renders nothing) when isInViewport is false", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
renderBadge("FN-100", false);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeFallbackBadge — mobile breakpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function mockMobileViewport() {
|
||||
Object.defineProperty(window, "innerWidth", { value: 375, configurable: true });
|
||||
Object.defineProperty(window, "innerHeight", { value: 812, configurable: true });
|
||||
if (!window.matchMedia) {
|
||||
Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn() });
|
||||
}
|
||||
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
it("still renders the badge with its message and data attributes at mobile viewport width", async () => {
|
||||
mockMobileViewport();
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
renderBadge();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
const badge = screen.getByTestId("runtime-fallback-badge");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.textContent).toContain("hermes");
|
||||
expect(badge.className).toContain("card-runtime-fallback-badge");
|
||||
});
|
||||
});
|
||||
112
packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
Normal file
112
packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* useRuntimeFallbackStatus — polls the lightweight `/api/tasks/:id/runtime-fallback`
|
||||
* endpoint (FUX-022) and derives whether the runtime-fallback badge should be
|
||||
* shown for a task, plus a one-shot toast trigger the first time a new
|
||||
* fallback session is observed.
|
||||
*
|
||||
* ## Why polling instead of the existing badge WebSocket (useBadgeWebSocket)?
|
||||
* `useBadgeWebSocket` is a GitHub/GitLab-specific protocol (`badge:updated`
|
||||
* messages carrying `prInfo`/`issueInfo`). Runtime-fallback state changes at
|
||||
* most once per agent session (session:runtime-resolved is written once per
|
||||
* createResolvedAgentSession call), so a low-frequency poll is simpler and
|
||||
* sufficient — extending the badge WS message protocol for a single new field
|
||||
* would add cross-cutting server/socket surface for no material latency win.
|
||||
* This hook only polls while `enabled` is true (callers should pass
|
||||
* `isInViewport` so off-screen cards do not generate background traffic).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy";
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface RuntimeFallbackStatus {
|
||||
/** True only when the latest resolution has wasConfigured=false and a non-empty runtimeHint. */
|
||||
showBadge: boolean;
|
||||
/** The configured runtime hint that could not be resolved, when showBadge is true. */
|
||||
runtimeHint: string | null;
|
||||
/** FallbackReason ("not_found" | "factory_error" | "init_error") when available. */
|
||||
reason: string | null;
|
||||
/** Human-readable badge/toast message, or null when there is nothing to show. */
|
||||
message: string | null;
|
||||
/** True exactly once, on the render where a newly-observed fallback session should fire a toast. */
|
||||
shouldToastNow: boolean;
|
||||
}
|
||||
|
||||
const IDLE_STATUS: RuntimeFallbackStatus = {
|
||||
showBadge: false,
|
||||
runtimeHint: null,
|
||||
reason: null,
|
||||
message: null,
|
||||
shouldToastNow: false,
|
||||
};
|
||||
|
||||
export function formatRuntimeFallbackMessage(runtimeHint: string): string {
|
||||
return `Runtime fallback: configured runtime '${runtimeHint}' unavailable, using default pi`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param taskId - Task to poll fallback status for. Pass undefined/empty to disable.
|
||||
* @param enabled - Gate polling (e.g. isInViewport) to avoid background traffic for off-screen cards.
|
||||
* @param projectId - Optional project scope for multi-project dashboards.
|
||||
*/
|
||||
export function useRuntimeFallbackStatus(
|
||||
taskId: string | undefined,
|
||||
enabled: boolean,
|
||||
projectId?: string,
|
||||
): RuntimeFallbackStatus {
|
||||
const [status, setStatus] = useState<RuntimeFallbackStatus>(IDLE_STATUS);
|
||||
// Dedupe key for toasts: last audit event ID we already toasted for. Persists across
|
||||
// polls/re-renders for the lifetime of the component so the toast fires exactly once
|
||||
// per newly-observed fallback session, not on every poll.
|
||||
const lastToastedEventIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !taskId) {
|
||||
setStatus(IDLE_STATUS);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const poll = async () => {
|
||||
let data: TaskRuntimeFallbackResponse;
|
||||
try {
|
||||
data = await fetchTaskRuntimeFallback(taskId, projectId);
|
||||
} catch {
|
||||
// Network hiccups shouldn't flip a shown badge back off; just skip this cycle.
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
if (!data.showFallbackBadge || !data.runtimeHint) {
|
||||
setStatus(IDLE_STATUS);
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewlyObserved = data.eventId !== null && data.eventId !== lastToastedEventIdRef.current;
|
||||
if (isNewlyObserved && data.eventId) {
|
||||
lastToastedEventIdRef.current = data.eventId;
|
||||
}
|
||||
|
||||
setStatus({
|
||||
showBadge: true,
|
||||
runtimeHint: data.runtimeHint,
|
||||
reason: data.reason,
|
||||
message: formatRuntimeFallbackMessage(data.runtimeHint),
|
||||
shouldToastNow: isNewlyObserved,
|
||||
});
|
||||
};
|
||||
|
||||
void poll();
|
||||
const interval = setInterval(() => {
|
||||
void poll();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [taskId, enabled, projectId]);
|
||||
|
||||
return status;
|
||||
}
|
||||
@@ -699,6 +699,33 @@ export interface RunAuditResponse {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for GET /api/tasks/:id/runtime-fallback
|
||||
*
|
||||
* Normalized view of the most recent "session:runtime-resolved" run-audit
|
||||
* event for a task, used to drive the dashboard's runtime-fallback
|
||||
* badge/toast affordance. `showFallbackBadge` is the single field UI
|
||||
* consumers should branch on: true only when the latest resolution had
|
||||
* `wasConfigured: false` for a non-empty configured `runtimeHint`.
|
||||
*/
|
||||
export interface TaskRuntimeFallbackResponse {
|
||||
taskId: string;
|
||||
/** Whether any session:runtime-resolved audit event exists for this task yet. */
|
||||
hasEvent: boolean;
|
||||
/** Whether the resolved runtime matched an explicitly configured hint. Null when hasEvent is false. */
|
||||
wasConfigured: boolean | null;
|
||||
/** The configured runtime hint from the most recent event, or null when absent/blank. */
|
||||
runtimeHint: string | null;
|
||||
/** FallbackReason ("not_found" | "factory_error" | "init_error") when wasConfigured is false, else null. */
|
||||
reason: string | null;
|
||||
/** Audit event ID, usable as a stable dedupe key for one-shot toasts. */
|
||||
eventId: string | null;
|
||||
/** ISO-8601 timestamp of the most recent event. */
|
||||
timestamp: string | null;
|
||||
/** True only when wasConfigured === false AND runtimeHint is non-empty. */
|
||||
showFallbackBadge: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for GET /api/agents/:id/runs/:runId/cited-goals
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore, RunAuditEvent } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
const makeTaskState = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "FN-001",
|
||||
description: "task with runtime hint",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-05-15T00:00:00.000Z",
|
||||
updatedAt: "2026-05-15T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as any);
|
||||
|
||||
const makeAuditEvent = (overrides: Partial<RunAuditEvent> = {}): RunAuditEvent => ({
|
||||
id: "audit-1",
|
||||
timestamp: "2026-07-08T00:00:00.000Z",
|
||||
taskId: "FN-001",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
domain: "database",
|
||||
mutationType: "session:runtime-resolved" as any,
|
||||
target: "pi",
|
||||
metadata: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createHarness = (taskState: any, events: RunAuditEvent[]) => {
|
||||
const store: TaskStore = {
|
||||
getRootDir: vi.fn(() => process.cwd()),
|
||||
getTask: vi.fn(async (id: string) => {
|
||||
if (id !== taskState.id) {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
return taskState;
|
||||
}),
|
||||
getRunAuditEvents: vi.fn((options: Record<string, unknown> = {}) => {
|
||||
let filtered = events;
|
||||
if (options.taskId) {
|
||||
filtered = filtered.filter((e) => e.taskId === options.taskId);
|
||||
}
|
||||
if (options.mutationType) {
|
||||
filtered = filtered.filter((e) => e.mutationType === options.mutationType);
|
||||
}
|
||||
// Events array in these fixtures is already provided most-recent-first,
|
||||
// matching the store's real ORDER BY timestamp DESC, rowid DESC.
|
||||
if (typeof options.limit === "number") {
|
||||
filtered = filtered.slice(0, options.limit);
|
||||
}
|
||||
return filtered;
|
||||
}),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return { app, store };
|
||||
};
|
||||
|
||||
describe("GET /api/tasks/:id/runtime-fallback", () => {
|
||||
it("returns hasEvent=false and showFallbackBadge=false when no session:runtime-resolved event exists", async () => {
|
||||
const { app } = createHarness(makeTaskState(), []);
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/FN-001/runtime-fallback");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasEvent).toBe(false);
|
||||
expect(res.body.showFallbackBadge).toBe(false);
|
||||
expect(res.body.wasConfigured).toBeNull();
|
||||
expect(res.body.runtimeHint).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show badge when the most recent event has wasConfigured=true", async () => {
|
||||
const { app } = createHarness(makeTaskState(), [
|
||||
makeAuditEvent({ metadata: { wasConfigured: true, runtimeHint: "hermes" } }),
|
||||
]);
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/FN-001/runtime-fallback");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasEvent).toBe(true);
|
||||
expect(res.body.wasConfigured).toBe(true);
|
||||
expect(res.body.showFallbackBadge).toBe(false);
|
||||
});
|
||||
|
||||
it("shows badge when wasConfigured=false and runtimeHint is non-empty", async () => {
|
||||
const { app } = createHarness(makeTaskState(), [
|
||||
makeAuditEvent({
|
||||
metadata: { wasConfigured: false, runtimeHint: "hermes", reason: "not_found" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/FN-001/runtime-fallback");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.hasEvent).toBe(true);
|
||||
expect(res.body.wasConfigured).toBe(false);
|
||||
expect(res.body.runtimeHint).toBe("hermes");
|
||||
expect(res.body.reason).toBe("not_found");
|
||||
expect(res.body.showFallbackBadge).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show badge when wasConfigured=false but runtimeHint is blank/absent", async () => {
|
||||
const { app } = createHarness(makeTaskState(), [
|
||||
makeAuditEvent({ metadata: { wasConfigured: false } }),
|
||||
]);
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/FN-001/runtime-fallback");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.wasConfigured).toBe(false);
|
||||
expect(res.body.runtimeHint).toBeNull();
|
||||
expect(res.body.showFallbackBadge).toBe(false);
|
||||
});
|
||||
|
||||
it("only the most recent event governs — a stale fallback superseded by a later success shows no badge", async () => {
|
||||
const { app } = createHarness(makeTaskState(), [
|
||||
// Most recent first, matching real store ordering.
|
||||
makeAuditEvent({ id: "audit-2", timestamp: "2026-07-08T01:00:00.000Z", metadata: { wasConfigured: true, runtimeHint: "hermes" } }),
|
||||
makeAuditEvent({ id: "audit-1", timestamp: "2026-07-08T00:00:00.000Z", metadata: { wasConfigured: false, runtimeHint: "hermes", reason: "not_found" } }),
|
||||
]);
|
||||
|
||||
const res = await REQUEST(app, "GET", "/api/tasks/FN-001/runtime-fallback");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.eventId).toBe("audit-2");
|
||||
expect(res.body.wasConfigured).toBe(true);
|
||||
expect(res.body.showFallbackBadge).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2882,6 +2882,77 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id/runtime-fallback
|
||||
* Return the most recent "session:runtime-resolved" run-audit event for
|
||||
* this task, normalized for the runtime-fallback badge/toast affordance.
|
||||
*
|
||||
* `showFallbackBadge` is true only when the most recent event has
|
||||
* `wasConfigured === false` AND a non-empty configured `runtimeHint` — a
|
||||
* missing hint (no runtime was ever configured) is not a misconfiguration
|
||||
* and must not surface a badge. Older fallback events are superseded by
|
||||
* any later successful resolution because only the single most recent
|
||||
* event (limit: 1, store-ordered most-recent-first) is considered.
|
||||
*
|
||||
* Response: TaskRuntimeFallbackResponse (see routes.ts)
|
||||
*/
|
||||
router.get("/tasks/:id/runtime-fallback", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const taskId = req.params.id;
|
||||
|
||||
// Verify task exists so callers get a clean 404 instead of an empty
|
||||
// "no event yet" response for a nonexistent task ID.
|
||||
await scopedStore.getTask(taskId);
|
||||
|
||||
const [latest] = scopedStore.getRunAuditEvents({
|
||||
taskId,
|
||||
mutationType: "session:runtime-resolved",
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
res.json({
|
||||
taskId,
|
||||
hasEvent: false,
|
||||
wasConfigured: null,
|
||||
runtimeHint: null,
|
||||
reason: null,
|
||||
eventId: null,
|
||||
timestamp: null,
|
||||
showFallbackBadge: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = latest.metadata ?? {};
|
||||
const wasConfigured = metadata.wasConfigured === true;
|
||||
const runtimeHintRaw = typeof metadata.runtimeHint === "string" ? metadata.runtimeHint.trim() : "";
|
||||
const runtimeHint = runtimeHintRaw.length > 0 ? runtimeHintRaw : null;
|
||||
const reason = typeof metadata.reason === "string" ? metadata.reason : null;
|
||||
|
||||
res.json({
|
||||
taskId,
|
||||
hasEvent: true,
|
||||
wasConfigured,
|
||||
runtimeHint,
|
||||
reason,
|
||||
eventId: latest.id,
|
||||
timestamp: latest.timestamp,
|
||||
showFallbackBadge: wasConfigured === false && runtimeHint !== null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw notFound(`Task ${req.params.id} not found`);
|
||||
} else {
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/tasks/stranded-refinements", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
|
||||
@@ -269,6 +269,7 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.fallbackReason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("should fall back to pi when openclaw runtime is not registered", async () => {
|
||||
@@ -278,6 +279,7 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.fallbackReason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("should fall back to pi when runtime factory throws", async () => {
|
||||
@@ -298,6 +300,7 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.fallbackReason).toBe("factory_error");
|
||||
});
|
||||
|
||||
it("should fall back to pi when runtime factory returns null", async () => {
|
||||
@@ -318,6 +321,7 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.fallbackReason).toBe("factory_error");
|
||||
});
|
||||
|
||||
it("should fall back to pi when createRuntimeContext returns null", async () => {
|
||||
@@ -333,6 +337,28 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.fallbackReason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("should report reason 'not_found' distinct from 'factory_error' across the two hint failure modes", async () => {
|
||||
// not_found: no registration at all
|
||||
mockPluginRunner.getRuntimeById.mockReturnValueOnce(undefined);
|
||||
const notFoundResult = await resolveRuntime(createContext("executor", "missing-plugin"));
|
||||
expect(notFoundResult.fallbackReason).toBe("not_found");
|
||||
|
||||
// factory_error: registration exists but factory throws during instantiation
|
||||
const throwingRuntime: PluginRuntimeRegistration = {
|
||||
metadata: { runtimeId: "throws", name: "Throwing Runtime" },
|
||||
factory: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
};
|
||||
mockPluginRunner.getRuntimeById.mockReturnValueOnce({
|
||||
pluginId: "throwing-plugin",
|
||||
runtime: throwingRuntime,
|
||||
});
|
||||
const factoryErrorResult = await resolveRuntime(createContext("executor", "throws"));
|
||||
expect(factoryErrorResult.fallbackReason).toBe("factory_error");
|
||||
|
||||
expect(notFoundResult.fallbackReason).not.toBe(factoryErrorResult.fallbackReason);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,6 +347,7 @@ export async function createResolvedAgentSession(
|
||||
mockProviderActive: isMockProviderId(runtimeOptions.defaultProvider),
|
||||
testModeActive: settings ? isTestModeActive(settings) : false,
|
||||
...(runtimeHint ? { runtimeHint } : {}),
|
||||
...("fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -68,6 +68,13 @@ export interface ResolvedRuntime {
|
||||
wasConfigured: boolean;
|
||||
/** The runtime ID that was resolved */
|
||||
runtimeId: string;
|
||||
/**
|
||||
* When wasConfigured is false because a configured hint could not be
|
||||
* resolved, the reason for that fallback ("not_found" vs "factory_error").
|
||||
* Absent when there was no configured hint at all, or when hint was
|
||||
* "pi"/"default" (no fallback occurred).
|
||||
*/
|
||||
fallbackReason?: FallbackReason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,21 +134,31 @@ export function getDefaultPiRuntime(): AgentRuntime {
|
||||
return defaultPiRuntimeInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a plugin runtime lookup attempt: either a successfully resolved
|
||||
* runtime, or a miss carrying the distinguishing FallbackReason so callers
|
||||
* can tell "never registered" apart from "factory threw".
|
||||
*/
|
||||
type ResolvePluginRuntimeResult =
|
||||
| { ok: true; runtime: AgentRuntime; pluginId: string }
|
||||
| { ok: false; reason: FallbackReason };
|
||||
|
||||
/**
|
||||
* Resolve a plugin runtime by its runtimeId.
|
||||
*
|
||||
* @param pluginRunner - PluginRunner for looking up runtimes
|
||||
* @param runtimeId - The runtime ID to find
|
||||
* @returns The resolved runtime wrapper, or null if not found
|
||||
* @returns The resolved runtime wrapper, or a miss tagged with the
|
||||
* FallbackReason distinguishing "not_found" from "factory_error"
|
||||
*/
|
||||
async function resolvePluginRuntime(
|
||||
pluginRunner: PluginRunner,
|
||||
runtimeId: string,
|
||||
): Promise<{ runtime: AgentRuntime; pluginId: string } | null> {
|
||||
): Promise<ResolvePluginRuntimeResult> {
|
||||
// Use the convenience method for single runtime lookup
|
||||
const registration = pluginRunner.getRuntimeById(runtimeId);
|
||||
if (!registration) {
|
||||
return null;
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
const { pluginId, runtime } = registration;
|
||||
@@ -152,7 +169,7 @@ async function resolvePluginRuntime(
|
||||
const pluginContext = await pluginRunner.createRuntimeContext(pluginId);
|
||||
if (!pluginContext) {
|
||||
runtimeLog.warn(`Plugin "${pluginId}" runtime factory context unavailable`);
|
||||
return null;
|
||||
return { ok: false, reason: "not_found" };
|
||||
}
|
||||
|
||||
// Instantiate the runtime via factory
|
||||
@@ -161,18 +178,18 @@ async function resolvePluginRuntime(
|
||||
|
||||
if (!instance) {
|
||||
runtimeLog.warn(`Plugin "${pluginId}" runtime factory returned null`);
|
||||
return null;
|
||||
return { ok: false, reason: "factory_error" };
|
||||
}
|
||||
|
||||
// Wrap the plugin runtime to conform to AgentRuntime interface
|
||||
// The plugin may return its own interface, so we adapt if needed
|
||||
const wrappedRuntime = wrapPluginRuntime(instance, runtime.metadata.runtimeId, runtime.metadata.name);
|
||||
|
||||
return { runtime: wrappedRuntime, pluginId };
|
||||
return { ok: true, runtime: wrappedRuntime, pluginId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeLog.error(`Plugin "${pluginId}" runtime factory error: ${message}`);
|
||||
return null;
|
||||
return { ok: false, reason: "factory_error" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,10 +303,11 @@ export async function resolveRuntime(context: RuntimeResolutionContext): Promise
|
||||
}
|
||||
|
||||
// Look up the plugin runtime
|
||||
let fallbackReason: FallbackReason = "not_found";
|
||||
try {
|
||||
const resolved = await resolvePluginRuntime(pluginRunner, runtimeId);
|
||||
|
||||
if (resolved) {
|
||||
if (resolved.ok) {
|
||||
runtimeLog.log(`[${sessionPurpose}] Using configured plugin runtime "${runtimeId}" from "${resolved.pluginId}"`);
|
||||
return {
|
||||
runtime: resolved.runtime,
|
||||
@@ -297,17 +315,21 @@ export async function resolveRuntime(context: RuntimeResolutionContext): Promise
|
||||
runtimeId,
|
||||
};
|
||||
}
|
||||
|
||||
fallbackReason = resolved.reason;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
runtimeLog.error(`[${sessionPurpose}] Error resolving plugin runtime "${runtimeId}": ${message}`);
|
||||
fallbackReason = "factory_error";
|
||||
}
|
||||
|
||||
// Case 3: Runtime not found or error — fall back to pi with warning
|
||||
logRuntimeFallback(sessionPurpose, runtimeId, "not_found");
|
||||
logRuntimeFallback(sessionPurpose, runtimeId, fallbackReason);
|
||||
return {
|
||||
runtime: getDefaultPiRuntime(),
|
||||
wasConfigured: false,
|
||||
runtimeId: "pi",
|
||||
fallbackReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user