Files
fusion/packages/dashboard/app/hooks/useRuntimeFallbackStatus.ts
gsxdsm 0039480334 fix(FUX-039): harden runtime-fallback agent-card viewport gating
Follow-up hardening on the RuntimeFallbackBadge viewport-gating work:

- AgentsView.tsx: registerAgentCardRef now returns a cached, stable callback per
  key (agentCardRefCallbacksRef) instead of a fresh closure each render. A fresh
  closure reads as unmount+remount to React; in environments without
  IntersectionObserver the mount path calls setVisibleAgentCardKeys -> re-render
  -> another fresh closure -> an infinite re-render loop (including jsdom). The
  cached entry is evicted on true unmount (el === null) so the Map cannot grow
  unbounded across created/deleted agents.
- ActiveAgentsPanel.tsx: document the viewport-gated badge polling with an FNXC
  comment (behavior unchanged).
- useRuntimeFallbackStatus.ts: guard __resetRuntimeFallbackToastDedupeStoreForTests
  to a no-op outside the test build (import.meta.env.MODE !== "test") so the
  test-only dedupe reset can never affect production code paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:24:01 -07:00

165 lines
6.3 KiB
TypeScript

/**
* 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, 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.
* Guarded to a no-op outside the test build (import.meta.env.MODE) so it can
* never affect production code paths.
*/
export function __resetRuntimeFallbackToastDedupeStoreForTests(): void {
if (import.meta.env.MODE !== "test") return;
toastedEventKeys.clear();
}
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);
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;
}
// 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,
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;
}