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:
Fusion
2026-07-08 03:09:29 -04:00
parent ca8447304f
commit 0bed997af8
13 changed files with 732 additions and 9 deletions

View File

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

View File

@@ -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 ? (

View File

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

View 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";

View File

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

View File

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

View 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;
}

View File

@@ -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
*/

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
};
}