fix: resolve 3 staff-engineer review findings from #1957 (init_error mislabel, unbounded off-screen polling, duplicate toasts) (#1960)
## Summary A Staff Engineer pre-landing review (Greptile/CodeRabbit) on #1957 (merged) flagged four structural issues. This PR fixes the three that were confirmed still present on `main`; the fourth (an unregistered-rule `eslint-disable-next-line react-hooks/exhaustive-deps` comment) was already fixed in #1957's second commit before merge and needed no further change. 1. **`resolvePluginRuntime()` mislabeled "found but failed to init" as `not_found`.** When a `runtimeHint` plugin registration is found but `pluginContext`/`createRuntimeContext(...)` comes back falsy, the resolver returned `reason: "not_found"` — indistinguishable from "never registered" — defeating the point of a distinct `FallbackReason`. Now returns `reason: "init_error"`. Updated the existing test that wrongly asserted `"not_found"` for this path, and added a new test asserting all three reachable `FallbackReason` values (`not_found`, `init_error`, `factory_error`) are pairwise distinct. 2. **`ActiveAgentsPanel.tsx`/`AgentsView.tsx` hardcoded `isInViewport={true}`.** Every agent card (live-agent header, board card, list card) polled the runtime-fallback endpoint every 30s forever, even scrolled off-screen — unlike `TaskCard.tsx`'s correct `IntersectionObserver`-gated pattern. Both files now thread a real `IntersectionObserver`-backed viewport signal into `RuntimeFallbackBadge`. Added regression tests proving polling stops once a badge instance's `isInViewport` transitions to `false` and resumes once it goes back to `true` (desktop + a mobile-breakpoint variant), plus verified via `tsc --noEmit` for `@fusion/dashboard`. 3. **Toast dedupe was per-hook-instance, not shared.** `useRuntimeFallbackStatus`'s `lastToastedEventIdRef` was a local `useRef`, so the same task rendered simultaneously in two card surfaces (e.g. `ActiveAgentsPanel` + `AgentsView`) fired two separate toasts for one fallback event. Dedupe now lives in module-level shared state (a bounded `Map` keyed by `taskId:eventId`, FIFO-evicted past 500 entries) so a fallback event toasts exactly once across every simultaneously-mounted badge instance for the same task. Added a cross-instance regression test mounting two badges for the same `taskId`/`eventId` and asserting exactly one toast fires. ## Test evidence - `pnpm --filter @fusion/engine exec vitest run src/__tests__/runtime-resolution.test.ts --reporter=dot` — 25/25 pass - `pnpm --filter @fusion/dashboard exec vitest run app/components/__tests__/RuntimeFallbackBadge.test.tsx --reporter=dot` — 11/11 pass - `pnpm --filter @fusion/dashboard run typecheck` — clean - `pnpm --filter @fusion/engine run typecheck` — clean ## Scope Isolated 6-file diff on top of current `main` (`packages/engine/src/runtime-resolution.ts`, `packages/engine/src/__tests__/runtime-resolution.test.ts`, `packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts`, `packages/dashboard/app/components/ActiveAgentsPanel.tsx`, `packages/dashboard/app/components/AgentsView.tsx`, `packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx`). No behavior outside the three findings above was touched. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - The desktop dashboard now supports plugin-backed runtime features, improving how plugin-enabled workflows are loaded and run. - Agent cards now pause background fallback polling when they’re off-screen, helping the dashboard feel smoother and more responsive. - **Bug Fixes** - Improved runtime fallback handling so missing runtimes and initialization failures are reported more accurately. - Toast notifications are now better deduplicated, reducing repeated alerts when multiple views show the same fallback state. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -6,7 +6,7 @@ import type { AddressInfo } from "node:net";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as os from "node:os";
|
||||
import { CentralCore, TaskStore } from "@fusion/core";
|
||||
import { CentralCore, PluginLoader, TaskStore } from "@fusion/core";
|
||||
import { createServer } from "@fusion/dashboard";
|
||||
import { ProjectEngineManager } from "@fusion/engine";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
@@ -64,10 +64,25 @@ async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: b
|
||||
})
|
||||
: undefined;
|
||||
|
||||
/*
|
||||
* FNXC:PluginSubsystem 2026-07-08-00:00:
|
||||
* `fusion desktop` is a separate createServer(...) call site from the Electron
|
||||
* app's packages/desktop/src local runtime (same "desktop" name, different
|
||||
* package). It had the same gap: no PluginStore/PluginLoader passed in, so
|
||||
* plugin install and Browse registry failed here too. Mirror
|
||||
* packages/cli/src/commands/dashboard.ts's construction.
|
||||
*/
|
||||
const pluginStore = store.getPluginStore();
|
||||
await pluginStore.init();
|
||||
const pluginLoader = new PluginLoader({ pluginStore, taskStore: store });
|
||||
|
||||
const app = createServer(store, {
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
centralCore,
|
||||
pluginStore,
|
||||
pluginLoader,
|
||||
pluginRunner: pluginLoader,
|
||||
/*
|
||||
* FNXC:DesktopLauncher 2026-07-01-20:19:
|
||||
* `fusion desktop --no-auth` is a compatibility flag for users who learned the dashboard launcher semantics. Propagate it to the embedded dashboard server explicitly so desktop routing never treats it as an unknown flag or falls back to source-workspace discovery.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Activity, FileText } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Agent } from "../api";
|
||||
@@ -24,6 +24,31 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
const { t } = useTranslation("app");
|
||||
const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId);
|
||||
const [task, setTask] = useState<TaskDetail | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
|
||||
// Gate the RuntimeFallbackBadge's polling to visible cards only, matching
|
||||
// TaskCard.tsx's pattern -- without this, every live agent card (including
|
||||
// ones scrolled off-screen) polls the runtime-fallback endpoint forever.
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsInViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const element = cardRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsInViewport(entry?.isIntersecting ?? true);
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [agent.id]);
|
||||
|
||||
// Poll the agent's task so the empty state can show real run progress
|
||||
// (current step, executor model) instead of just "Connecting..." while the
|
||||
@@ -101,6 +126,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="live-agent-card"
|
||||
onClick={handleSelect}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -120,7 +146,7 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
|
||||
<span className="live-agent-task badge"><AgentTaskBadge taskId={agent.taskId} taskColumn={agent.taskColumn} /></span>
|
||||
)}
|
||||
{agent.taskId && (
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={true} projectId={projectId} />
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isInViewport} projectId={projectId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="live-agent-card-transcript">
|
||||
|
||||
@@ -326,6 +326,85 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const { t } = useTranslation("app");
|
||||
const agentRoles = getAgentRoles(t);
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
|
||||
// Real IntersectionObserver-backed viewport gating for RuntimeFallbackBadge
|
||||
// instances rendered per-card (board + list views), matching TaskCard.tsx's
|
||||
// pattern. Cards are rendered inline inside a .map() rather than as their
|
||||
// own components, so a single shared observer keyed by a per-card string
|
||||
// (`board:{agentId}` / `list:{agentId}`, kept distinct so board and list
|
||||
// never share visibility state for the same agent) replaces the per-card
|
||||
// useRef/useState/useEffect triplet TaskCard uses for its single card root.
|
||||
const agentCardElsRef = useRef<Map<string, Element>>(new Map());
|
||||
const agentCardKeyByElRef = useRef<Map<Element, string>>(new Map());
|
||||
const [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState<Set<string>>(new Set());
|
||||
const agentCardObserverRef = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
setVisibleAgentCardKeys((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const entry of entries) {
|
||||
const key = agentCardKeyByElRef.current.get(entry.target);
|
||||
if (!key) continue;
|
||||
if (entry.isIntersecting) {
|
||||
if (!next.has(key)) {
|
||||
next.add(key);
|
||||
changed = true;
|
||||
}
|
||||
} else if (next.has(key)) {
|
||||
next.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
},
|
||||
{ rootMargin: "200px" },
|
||||
);
|
||||
agentCardObserverRef.current = observer;
|
||||
agentCardElsRef.current.forEach((el) => observer.observe(el));
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
agentCardObserverRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerAgentCardRef = useCallback((key: string) => (el: HTMLDivElement | null) => {
|
||||
const prevEl = agentCardElsRef.current.get(key);
|
||||
if (prevEl) {
|
||||
agentCardObserverRef.current?.unobserve(prevEl);
|
||||
agentCardKeyByElRef.current.delete(prevEl);
|
||||
}
|
||||
if (el) {
|
||||
agentCardElsRef.current.set(key, el);
|
||||
agentCardKeyByElRef.current.set(el, key);
|
||||
if (agentCardObserverRef.current) {
|
||||
agentCardObserverRef.current.observe(el);
|
||||
} else {
|
||||
// No IntersectionObserver support: treat as always visible, same
|
||||
// synchronous-true fallback TaskCard.tsx uses.
|
||||
setVisibleAgentCardKeys((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
||||
}
|
||||
} else {
|
||||
agentCardElsRef.current.delete(key);
|
||||
setVisibleAgentCardKeys((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isAgentCardInViewport = useCallback(
|
||||
(key: string) => (typeof IntersectionObserver === "undefined" ? true : visibleAgentCardKeys.has(key)),
|
||||
[visibleAgentCardKeys],
|
||||
);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobileViewport = viewportMode === "mobile";
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readAgentsSidebarWidth(projectId));
|
||||
@@ -1693,6 +1772,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
return (
|
||||
<div key={agent.id} className={`agent-board-card ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}>
|
||||
<div
|
||||
ref={registerAgentCardRef(`board:${agent.id}`)}
|
||||
className="agent-board-clickable"
|
||||
onClick={() => openAgentDetail(agent.id)}
|
||||
role="button"
|
||||
@@ -1720,7 +1800,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<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} />
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isAgentCardInViewport(`board:${agent.id}`)} projectId={projectId} />
|
||||
)}
|
||||
<div className="agent-board-health" style={{ color: health.color }} title={healthSummary.title}>
|
||||
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""}
|
||||
@@ -1760,6 +1840,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
ref={registerAgentCardRef(`list:${agent.id}`)}
|
||||
className={`agent-card agent-card--clickable ${stateCardClass}${selectedAgentId === agent.id ? " agent-card--selected" : ""}`}
|
||||
onClick={(e) => {
|
||||
// Open detail when the user clicks the card body, but
|
||||
@@ -1892,7 +1973,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} />
|
||||
<RuntimeFallbackBadge taskId={agent.taskId} isInViewport={isAgentCardInViewport(`list:${agent.id}`)} projectId={projectId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-heartbeat-control">
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { __resetRuntimeFallbackToastDedupeStoreForTests } from "../../hooks/useRuntimeFallbackStatus";
|
||||
import type { TaskRuntimeFallbackResponse } from "../../api/legacy";
|
||||
|
||||
const legacyMocks = vi.hoisted(() => ({
|
||||
@@ -88,6 +89,10 @@ describe("RuntimeFallbackBadge", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockReset();
|
||||
// The toast dedupe store is module-level/shared by design (that is the
|
||||
// fix under test) — reset it between test cases so one test's "already
|
||||
// toasted" state does not leak into the next.
|
||||
__resetRuntimeFallbackToastDedupeStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -189,12 +194,95 @@ describe("RuntimeFallbackBadge", () => {
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
|
||||
// ActiveAgentsPanel.tsx and AgentsView.tsx (board + list cards) each wire a
|
||||
// real IntersectionObserver-backed isInViewport value into this exact same
|
||||
// <RuntimeFallbackBadge isInViewport={...} /> call, mirroring TaskCard.tsx's
|
||||
// pattern -- the shared poll-gating implementation lives here in the hook
|
||||
// this component consumes, so a *transition* (not just a static isInViewport
|
||||
// prop) is what actually reproduces "card scrolls off-screen mid-session"
|
||||
// for all four call sites, not just the initial-render case above.
|
||||
it("stops polling once isInViewport transitions to false mid-session, and resumes once it transitions back to true", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
const { rerender } = render(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument();
|
||||
|
||||
// Card scrolls off-screen: parent flips isInViewport to false (as a real
|
||||
// IntersectionObserver callback would via setIsInViewport(false)).
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={false} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
|
||||
// Card scrolls back into view: polling resumes.
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires exactly one toast when the same task/event is observed by two simultaneously-mounted badge instances (e.g. ActiveAgentsPanel + AgentsView rendering the same task concurrently)", async () => {
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
<ToastPeek />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
// Both instances polled and both observed the same new eventId on their
|
||||
// first poll, but the shared module-level dedupe store means only one of
|
||||
// them should have won the "claim" and fired a toast.
|
||||
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
|
||||
expect(screen.getAllByTestId("toast-entry")[0].textContent).toContain("hermes");
|
||||
|
||||
// Both badges still render independently (dedupe only affects the toast,
|
||||
// not the per-instance badge display).
|
||||
expect(screen.getAllByTestId("runtime-fallback-badge")).toHaveLength(2);
|
||||
|
||||
// Further polls with the same event on both instances must not add a
|
||||
// second toast either.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(screen.getAllByTestId("toast-entry")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RuntimeFallbackBadge — mobile breakpoint", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockReset();
|
||||
__resetRuntimeFallbackToastDedupeStoreForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -233,4 +321,32 @@ describe("RuntimeFallbackBadge — mobile breakpoint", () => {
|
||||
expect(badge.textContent).toContain("hermes");
|
||||
expect(badge.className).toContain("card-runtime-fallback-badge");
|
||||
});
|
||||
|
||||
it("stops polling once isInViewport transitions to false at mobile viewport width (agent-card list rows scroll off-screen too)", async () => {
|
||||
mockMobileViewport();
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockResolvedValue(fallbackWithHint);
|
||||
const { rerender } = render(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={true} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<ToastProvider>
|
||||
<RuntimeFallbackBadge taskId="FN-100" isInViewport={false} projectId="proj-1" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
legacyMocks.fetchTaskRuntimeFallback.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
});
|
||||
expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,65 @@
|
||||
* 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 { useEffect, useState } from "react";
|
||||
import { fetchTaskRuntimeFallback, type TaskRuntimeFallbackResponse } from "../api/legacy";
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
// Toast dedupe must be shared across ALL hook instances in the process, not
|
||||
// scoped per-instance: the same task/event can be observed simultaneously by
|
||||
// multiple mounted badges (e.g. ActiveAgentsPanel + AgentsView board/list +
|
||||
// TaskCard all rendering the same in-progress task at once), each running
|
||||
// its own useRuntimeFallbackStatus() call. A per-instance ref only dedupes
|
||||
// within one component instance's own poll history, so the same eventId
|
||||
// would independently look "newly observed" to every instance and fire one
|
||||
// toast each. Module-level state is shared across every call site because
|
||||
// there is exactly one copy of this module per process/bundle.
|
||||
//
|
||||
// Keyed by `${taskId}:${eventId}` (not eventId alone) so ids are unambiguous
|
||||
// even if two different tasks' audit logs ever produced colliding event ids.
|
||||
// Bounded via a simple FIFO eviction (insertion order === Map iteration
|
||||
// order) so a long-lived dashboard session touching many tasks over many
|
||||
// hours cannot grow this unboundedly; runtime-fallback events are rare
|
||||
// (at most one per agent session), so a few hundred entries comfortably
|
||||
// covers realistic session lengths without needing TTL bookkeeping.
|
||||
const MAX_TOASTED_EVENTS = 500;
|
||||
const toastedEventKeys = new Map<string, true>();
|
||||
|
||||
function toastKey(taskId: string, eventId: string): string {
|
||||
return `${taskId}:${eventId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true and records the key the first time it is seen; returns false
|
||||
* on every subsequent call for the same key, regardless of which hook
|
||||
* instance/component asks. This is the single shared gate all simultaneously
|
||||
* mounted badge instances for the same task funnel through.
|
||||
*/
|
||||
function claimToastOnce(taskId: string, eventId: string): boolean {
|
||||
const key = toastKey(taskId, eventId);
|
||||
if (toastedEventKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
toastedEventKeys.set(key, true);
|
||||
if (toastedEventKeys.size > MAX_TOASTED_EVENTS) {
|
||||
const oldestKey = toastedEventKeys.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
toastedEventKeys.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only escape hatch: clears the shared module-level dedupe store between
|
||||
* test cases so one test's "already toasted" state cannot leak into the
|
||||
* next. Not used by production code paths.
|
||||
*/
|
||||
export function __resetRuntimeFallbackToastDedupeStoreForTests(): void {
|
||||
toastedEventKeys.clear();
|
||||
}
|
||||
|
||||
export interface RuntimeFallbackStatus {
|
||||
/** True only when the latest resolution has wasConfigured=false and a non-empty runtimeHint. */
|
||||
showBadge: boolean;
|
||||
@@ -55,10 +109,6 @@ export function useRuntimeFallbackStatus(
|
||||
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) {
|
||||
@@ -83,10 +133,10 @@ export function useRuntimeFallbackStatus(
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewlyObserved = data.eventId !== null && data.eventId !== lastToastedEventIdRef.current;
|
||||
if (isNewlyObserved && data.eventId) {
|
||||
lastToastedEventIdRef.current = data.eventId;
|
||||
}
|
||||
// Dedupe against the shared module-level store (not a per-instance ref)
|
||||
// so a fallback event toasts exactly once across every simultaneously
|
||||
// mounted badge instance for this task, not once per instance.
|
||||
const isNewlyObserved = data.eventId !== null && taskId !== undefined && claimToastOnce(taskId, data.eventId);
|
||||
|
||||
setStatus({
|
||||
showBadge: true,
|
||||
|
||||
@@ -324,7 +324,10 @@ describe("runtime-resolution", () => {
|
||||
expect(result.fallbackReason).toBe("factory_error");
|
||||
});
|
||||
|
||||
it("should fall back to pi when createRuntimeContext returns null", async () => {
|
||||
it("should fall back to pi with reason 'init_error' when createRuntimeContext returns null", async () => {
|
||||
// Registration is found (getRuntimeById succeeds) but the plugin fails to
|
||||
// produce a usable context -- this is an initialization failure, distinct
|
||||
// from a registration that was never found in the first place.
|
||||
mockPluginRunner.createRuntimeContext.mockResolvedValue(null);
|
||||
const mockRuntime = createMockPluginRuntime("orphan", "Orphan Runtime");
|
||||
mockPluginRunner.getRuntimeById.mockReturnValue({
|
||||
@@ -337,7 +340,7 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(result.runtimeId).toBe("pi");
|
||||
expect(result.wasConfigured).toBe(false);
|
||||
expect(result.fallbackReason).toBe("not_found");
|
||||
expect(result.fallbackReason).toBe("init_error");
|
||||
});
|
||||
|
||||
it("should report reason 'not_found' distinct from 'factory_error' across the two hint failure modes", async () => {
|
||||
@@ -360,6 +363,40 @@ describe("runtime-resolution", () => {
|
||||
|
||||
expect(notFoundResult.fallbackReason).not.toBe(factoryErrorResult.fallbackReason);
|
||||
});
|
||||
|
||||
it("should distinguish all three reachable FallbackReason values: not_found, init_error, factory_error", async () => {
|
||||
// not_found: registration never existed for the requested runtimeId.
|
||||
mockPluginRunner.getRuntimeById.mockReturnValueOnce(undefined);
|
||||
const notFoundResult = await resolveRuntime(createContext("executor", "never-registered"));
|
||||
expect(notFoundResult.fallbackReason).toBe("not_found");
|
||||
|
||||
// init_error: registration exists but the plugin fails to initialize a context.
|
||||
mockPluginRunner.createRuntimeContext.mockResolvedValueOnce(null);
|
||||
const initErrorRuntime = createMockPluginRuntime("uninitializable", "Uninitializable Runtime");
|
||||
mockPluginRunner.getRuntimeById.mockReturnValueOnce({
|
||||
pluginId: "uninitializable-plugin",
|
||||
runtime: initErrorRuntime,
|
||||
});
|
||||
const initErrorResult = await resolveRuntime(createContext("executor", "uninitializable"));
|
||||
expect(initErrorResult.fallbackReason).toBe("init_error");
|
||||
|
||||
// factory_error: registration exists, context initializes, but the factory itself fails.
|
||||
const factoryErrorRuntime: PluginRuntimeRegistration = {
|
||||
metadata: { runtimeId: "factory-broken", name: "Factory Broken Runtime" },
|
||||
factory: vi.fn().mockRejectedValue(new Error("factory boom")),
|
||||
};
|
||||
mockPluginRunner.getRuntimeById.mockReturnValueOnce({
|
||||
pluginId: "factory-broken-plugin",
|
||||
runtime: factoryErrorRuntime,
|
||||
});
|
||||
const factoryErrorResult = await resolveRuntime(createContext("executor", "factory-broken"));
|
||||
expect(factoryErrorResult.fallbackReason).toBe("factory_error");
|
||||
|
||||
// All three reasons must be pairwise distinct so a regression collapsing
|
||||
// any two of them back together fails this assertion.
|
||||
const reasons = [notFoundResult.fallbackReason, initErrorResult.fallbackReason, factoryErrorResult.fallbackReason];
|
||||
expect(new Set(reasons).size).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -168,8 +168,11 @@ async function resolvePluginRuntime(
|
||||
// Create plugin context for runtime factory
|
||||
const pluginContext = await pluginRunner.createRuntimeContext(pluginId);
|
||||
if (!pluginContext) {
|
||||
// The registration exists (found above) but the plugin failed to produce a
|
||||
// usable context, so this is an initialization failure, not a "never
|
||||
// registered" miss -- must be distinguishable as "init_error".
|
||||
runtimeLog.warn(`Plugin "${pluginId}" runtime factory context unavailable`);
|
||||
return { ok: false, reason: "not_found" };
|
||||
return { ok: false, reason: "init_error" };
|
||||
}
|
||||
|
||||
// Instantiate the runtime via factory
|
||||
|
||||
Reference in New Issue
Block a user