From c3c726cff472b502aa4e6865874990607a428b70 Mon Sep 17 00:00:00 2001 From: ddonaldson130 Date: Wed, 8 Jul 2026 12:29:14 -0400 Subject: [PATCH 1/3] fix(FUX-039): return init_error for found-but-uninitialized plugin runtime Co-authored-by: Fusion --- packages/cli/src/commands/desktop.ts | 17 +++++++- .../src/__tests__/runtime-resolution.test.ts | 41 ++++++++++++++++++- packages/engine/src/runtime-resolution.ts | 5 ++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/desktop.ts b/packages/cli/src/commands/desktop.ts index d3a75f9ee9..10592303bd 100644 --- a/packages/cli/src/commands/desktop.ts +++ b/packages/cli/src/commands/desktop.ts @@ -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. diff --git a/packages/engine/src/__tests__/runtime-resolution.test.ts b/packages/engine/src/__tests__/runtime-resolution.test.ts index 4bcd318026..f1592ebc05 100644 --- a/packages/engine/src/__tests__/runtime-resolution.test.ts +++ b/packages/engine/src/__tests__/runtime-resolution.test.ts @@ -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); + }); }); }); diff --git a/packages/engine/src/runtime-resolution.ts b/packages/engine/src/runtime-resolution.ts index 9f22fa3bc6..748a88a40a 100644 --- a/packages/engine/src/runtime-resolution.ts +++ b/packages/engine/src/runtime-resolution.ts @@ -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 From 8a35b0bc4c5822ba66db4a9e895581f2f5b8513c Mon Sep 17 00:00:00 2001 From: ddonaldson130 Date: Wed, 8 Jul 2026 12:41:16 -0400 Subject: [PATCH 2/3] fix(FUX-039): thread real IntersectionObserver viewport state into agent cards Co-authored-by: Fusion --- .../app/components/ActiveAgentsPanel.tsx | 30 ++++++- .../dashboard/app/components/AgentsView.tsx | 85 ++++++++++++++++++- .../__tests__/RuntimeFallbackBadge.test.tsx | 77 +++++++++++++++++ 3 files changed, 188 insertions(+), 4 deletions(-) diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.tsx b/packages/dashboard/app/components/ActiveAgentsPanel.tsx index 8f769651db..57441a1d5d 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.tsx +++ b/packages/dashboard/app/components/ActiveAgentsPanel.tsx @@ -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(null); + const cardRef = useRef(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 (
)} {agent.taskId && ( - + )}
diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index 0feabf0f8d..a98ccbe32b 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -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>(new Map()); + const agentCardKeyByElRef = useRef>(new Map()); + const [visibleAgentCardKeys, setVisibleAgentCardKeys] = useState>(new Set()); + const agentCardObserverRef = useRef(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(() => readAgentsSidebarWidth(projectId)); @@ -1693,6 +1772,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin return (
openAgentDetail(agent.id)} role="button" @@ -1720,7 +1800,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{agent.name}
{agent.id}
{agent.taskId && ( - + )}
{health.icon}{healthSummary.label ? ` ${healthSummary.label}` : ""} @@ -1760,6 +1840,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin return (
{ // Open detail when the user clicks the card body, but @@ -1892,7 +1973,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{t("agents.workingOn", "Working on:")} - +
)}
diff --git a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx index bf57316d49..349cc74b3a 100644 --- a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx +++ b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx @@ -189,6 +189,55 @@ 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 + // 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( + + + , + ); + + 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( + + + , + ); + 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( + + + , + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalled(); + expect(screen.getByTestId("runtime-fallback-badge")).toBeInTheDocument(); + }); }); describe("RuntimeFallbackBadge — mobile breakpoint", () => { @@ -233,4 +282,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( + + + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + legacyMocks.fetchTaskRuntimeFallback.mockClear(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(legacyMocks.fetchTaskRuntimeFallback).not.toHaveBeenCalled(); + expect(screen.queryByTestId("runtime-fallback-badge")).toBeNull(); + }); }); From 2479081415abe3360e7d29f08e0e26ca8817c459 Mon Sep 17 00:00:00 2001 From: ddonaldson130 Date: Wed, 8 Jul 2026 12:47:13 -0400 Subject: [PATCH 3/3] test(FUX-039): add cross-instance toast dedupe regression test Co-authored-by: Fusion --- .../__tests__/RuntimeFallbackBadge.test.tsx | 39 +++++++++++ .../app/hooks/useRuntimeFallbackStatus.ts | 68 ++++++++++++++++--- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx index 349cc74b3a..1db788b5bc 100644 --- a/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx +++ b/packages/dashboard/app/components/__tests__/RuntimeFallbackBadge.test.tsx @@ -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(() => { @@ -238,12 +243,46 @@ describe("RuntimeFallbackBadge", () => { 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( + + + + + , + ); + + 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(() => { diff --git a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts index 9a876f4978..cf65dff81e 100644 --- a/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts +++ b/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts @@ -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(); + +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(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(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,