Files
fusion/packages/dashboard/app/hooks/useMultiAgentLogs.ts
gsxdsm f26cbedf4f fix(dashboard): close the code-review findings on the mobile tab-discard work
An 11-reviewer pass over f157bf7460..f5163d8351 found defects in the mobile
tab-discard change set itself. This fixes them.

Silent data loss (the recurring defect class):
- AgentDetailView reconnect refetched limit:100 and replaced wholesale, so 380
  displayed lines vanished with no "Load older" and no indicator; it now
  reconciles through the shared logStreamReconcile helper.
- useActivityLog.loadMore past the cap discarded the page it had just fetched
  while advancing the cursor and leaving hasMore true, so the feed silently
  stopped paginating behind a live-looking button.
- useAgentLogs: loadMore and resyncFromServer had no mutual exclusion, a
  no-overlap resync discarded explicitly paged-back history, a resync outliving
  the reconnect delay left an unmarked gap, and the live-tail trim could evict
  the gap marker itself.
- useLiveTranscript's resync overwrote live entries that raced the refetch.

The premise itself was not fully delivered:
- useProjects, useNodes, and useMeshState never called clearInterval, so they
  polled the whole time the tab was hidden. useProjects is mounted for the
  entire session, so the page never went idle -- the primary mechanism this
  work depends on. All three now use the shared visibility gate.
- sse-bus fired onReconnect twice per reconnect cycle and fanned out ~28
  subscribers in one tick, against a ~6-connection-per-origin cap on a waking
  radio. The successful open is now the single authority, and the fan-out uses
  the same exported stagger primitive as the polling path rather than a second
  copy of the slot formula.
- A channel first subscribed during the hidden window opened a live EventSource
  and keepalive; suspension is now a module-level condition openChannel
  consults, and a channel opened inside the grace window re-arms it.

Credentials and correctness:
- The service worker persisted every GET /api/* to durable Cache Storage,
  including /api/settings with daemonToken, githubAuthToken, gitlabAuthToken
  and ntfyAccessToken in plaintext, with no exclusion and no purge path --
  "Clear all cached data" only walked localStorage. Now gated, bounded, and
  genuinely purgeable.
- useTasks cleared its own snapshot when the mount revalidation failed on a
  waking radio, so the board blanked and the next restore was empty too.
  Suspension-class failures no longer destroy the cache.
- A single-row SSE update reset lastFetchTimeMs to now while an hours-old
  hydrated snapshot was on screen, re-marking every in-progress card stuck.
- ListView's "Select all visible tasks" acted on the full filtered set while
  only 50 rows rendered, so a bulk delete reached rows the operator could not
  see. Column's search window reset keyed on a boolean, so refining a query
  kept the expanded window.

Tests that could not fail:
- App.test.tsx mocked TerminalModal as isOpen ? <div/> : null, making the
  unmount-on-close invariant unobservable; MockEventSource kept its listeners
  after close(), so cases passed with their onReconnect handlers deleted.
- The SSE resync ratchet scanned only hooks/, exempting ~13 component call
  sites -- the exact regression it exists to prevent.
- MissionControlPanel's bespoke poll and the xterm scrollback constants and
  WebGL disposal had no coverage at all.

Verified: tsc -p tsconfig.app.json clean, pnpm lint clean, pnpm
check:changesets clean, 877 tests passing across 36 scoped files.
Known unrelated red: MailboxView.test.tsx's FN-8407 CSS guard fails at HEAD
too -- this diff adds no @media rule and no .mailbox-view--mobile selector,
the only two things that assertion inspects. Left alone deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:17:52 -07:00

558 lines
21 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogsWithMeta } from "../api";
import { subscribeSse } from "../sse-bus";
import {
appendLiveEntry,
appendWithoutDuplicates,
capLogEntries,
countLeadingGapMarkers,
isLogGapMarker,
reconcileReconnectedEntries,
} from "./logStreamReconcile";
/*
FNXC:AgentLogResync 2026-07-26-16:35:
Buffer bounding, gap marking, and reconnect reconciliation are imported from ./logStreamReconcile,
the single implementation shared with useAgentLogs. Both hooks tail the same live-only
`/api/tasks/:id/logs/stream`; a hand-copied reconcile is exactly how the silent suspend-gap defect
survived a second round, so this hook must never grow a private variant.
Re-exported because existing call sites and tests import MAX_LOG_ENTRIES from this module.
*/
export { MAX_LOG_ENTRIES } from "./logStreamReconcile";
const INITIAL_LOAD_LIMIT = 100;
export interface TaskLogState {
entries: AgentLogEntry[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
total: number | null;
clear: () => void;
loadMore: () => Promise<void>;
}
export type LogStateMap = Record<string, TaskLogState>;
interface InitState {
entries: AgentLogEntry[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
total: number | null;
}
/**
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
*
* Features:
* - **Pagination**: Initial load fetches 100 entries per task. Use `loadMore()` to fetch older entries per task.
* - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
* - **Live streaming**: SSE events append new entries to the end of each task's list.
*
* For each task ID in the provided array:
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=100
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order
*
* When task IDs are added or removed, connections are opened/closed accordingly.
* When the component unmounts, all EventSources are closed to prevent memory leaks.
*
* **Reconnect semantics**: the stream replays nothing on open, so every reconnect (SSE error,
* heartbeat timeout, or the hidden-tab suspend/resume) refetches that task's authoritative newest
* page and reconciles it with the buffer. Where reconciliation cannot be proven, a gap marker is
* rendered (`isLogGapMarker`) rather than implying continuity.
*/
export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogStateMap {
// Store state per task
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
// Refs for state that needs to survive re-renders
const unsubscribesRef = useRef<Record<string, () => void>>({});
const initializingRef = useRef<Set<string>>(new Set());
const cancelledRef = useRef<Record<string, boolean>>({});
const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({});
const loadingMoreRef = useRef<Record<string, boolean>>({});
/*
FNXC:AgentLogResync 2026-07-26-16:40:
Reconnect bookkeeping, per task. `resyncInFlightRef` collapses a burst of reconnects into one
refetch; `resyncPendingRef` parks live events that race that refetch so the merge sees a stable
buffer snapshot and cannot duplicate (event present in both the page and the stream) or swallow
(event applied to a buffer the merge then overwrites) a line.
*/
const resyncInFlightRef = useRef<Record<string, boolean>>({});
const resyncPendingRef = useRef<Record<string, AgentLogEntry[]>>({});
/*
FNXC:AgentLogPaging 2026-07-26-16:44:
"The live tail dropped older entries" flag, per task. It forces `hasMore` on so the reader always
has a way back to the truncated history, and is sticky ONLY until an authoritative fetch reports
`hasMore:false` — that response proves the buffer reaches entry 0, so a permanently-true flag would
leave a "load older" control that fetches past the end and never changes anything.
*/
const trimmedLiveTailRef = useRef<Record<string, boolean>>({});
// Track project context version to detect stale events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
const projectContextVersionRef = useRef(0);
// Track previous projectId to detect project switches
const previousProjectIdRef = useRef<string | undefined>(projectId);
// Detect project switch and clear all state immediately
const projectSwitched = previousProjectIdRef.current !== projectId;
if (projectSwitched) {
previousProjectIdRef.current = projectId;
projectContextVersionRef.current++;
// Drop all existing SSE subscriptions and reset state
for (const [taskId, unsub] of Object.entries(unsubscribesRef.current)) {
cancelledRef.current[taskId] = true;
unsub();
}
unsubscribesRef.current = {};
initializingRef.current.clear();
cancelledRef.current = {};
pendingLiveEntriesRef.current = {};
loadingMoreRef.current = {};
resyncInFlightRef.current = {};
resyncPendingRef.current = {};
trimmedLiveTailRef.current = {};
// Clear all state immediately to prevent stale data visibility
setStateMap({});
}
// Create clear function for a specific task
const createClearFn = useCallback((taskId: string) => {
return () => {
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
pendingLiveEntriesRef.current[taskId] = [];
resyncPendingRef.current[taskId] = [];
trimmedLiveTailRef.current[taskId] = false;
return {
...prev,
[taskId]: { ...current, entries: [] },
};
});
};
}, []);
// Create loadMore function for a specific task
const createLoadMoreFn = useCallback((taskId: string, currentEntries: AgentLogEntry[]) => {
return async () => {
if (loadingMoreRef.current[taskId]) return;
const contextVersionAtStart = projectContextVersionRef.current;
loadingMoreRef.current[taskId] = true;
/*
FNXC:AgentLogPaging 2026-07-26-16:50:
The server offset counts back from the newest entry, so it must be the number of REAL entries
held. A synthetic gap marker is client-only and would shift the whole page by one, re-fetching
an entry the reader already has and leaving a one-entry hole below it.
*/
const offset = currentEntries.length - countLeadingGapMarkers(currentEntries);
// Update loading state
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return { ...prev, [taskId]: { ...current, loadingMore: true } };
});
try {
const result = await fetchAgentLogsWithMeta(taskId, projectId, {
limit: INITIAL_LOAD_LIMIT,
offset,
});
// Reject stale response
if (cancelledRef.current[taskId] ||
projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
/*
FNXC:AgentLogPaging 2026-07-26-16:52:
Older entries are PREPENDED — they belong below what the reader already holds, and the
previous append-to-the-end version rendered them as if the agent had just emitted them.
The result is deliberately NOT re-capped: capLogEntries keeps the NEWEST N, so capping here
would discard the very page the reader just asked for. Paging is an explicit expansion of the
buffer; the ring ceiling only governs unattended live growth.
A gap marker at the head is retired once real data closes the hole (page returned entries) or
the server proves nothing older exists (`hasMore:false`).
*/
const retireGapMarker = result.entries.length > 0 || !result.hasMore;
if (!result.hasMore) trimmedLiveTailRef.current[taskId] = false;
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
const base = retireGapMarker
? current.entries.filter((entry) => !isLogGapMarker(entry))
: current.entries;
return {
...prev,
[taskId]: {
...current,
entries: [...result.entries, ...base],
hasMore: result.hasMore,
total: result.total,
loadingMore: false,
},
};
});
} catch {
// Silently fail on load more errors
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return { ...prev, [taskId]: { ...current, loadingMore: false } };
});
} finally {
loadingMoreRef.current[taskId] = false;
}
};
}, [projectId]);
// Stable comparison of task IDs and projectId to prevent effect re-runs on every render
const taskIdsKey = taskIds.join(",");
const stableKey = [taskIdsKey, projectId ?? ""].join("|");
// Main effect to manage connections
useEffect(() => {
const currentIds = new Set(taskIds);
const subs = unsubscribesRef.current;
const initializing = initializingRef.current;
const cancelled = cancelledRef.current;
// Capture context version at effect start - stale events will be rejected
const contextVersionAtStart = projectContextVersionRef.current;
// Track which task IDs need state initialization (not already in stateMap)
const newTaskIds: string[] = [];
for (const taskId of taskIds) {
if (!stateMap[taskId]) {
newTaskIds.push(taskId);
}
}
// Only initialize state for new tasks that aren't already in stateMap
if (newTaskIds.length > 0) {
setStateMap((prev) => {
const updates: Record<string, InitState> = {};
for (const taskId of newTaskIds) {
if (!prev[taskId]) {
updates[taskId] = { entries: [], loading: true, loadingMore: false, hasMore: false, total: null };
}
}
if (Object.keys(updates).length === 0) return prev;
return { ...prev, ...updates };
});
}
// Drop subscriptions for tasks no longer in the list
const removedTaskIds: string[] = [];
for (const [taskId, unsub] of Object.entries(subs)) {
if (!currentIds.has(taskId)) {
cancelled[taskId] = true;
unsub();
delete subs[taskId];
initializing.delete(taskId);
delete cancelled[taskId];
delete pendingLiveEntriesRef.current[taskId];
delete loadingMoreRef.current[taskId];
delete resyncInFlightRef.current[taskId];
delete resyncPendingRef.current[taskId];
delete trimmedLiveTailRef.current[taskId];
removedTaskIds.push(taskId);
}
}
// Only remove state for disconnected tasks if there are any
if (removedTaskIds.length > 0) {
setStateMap((prev) => {
let hasChanges = false;
for (const taskId of removedTaskIds) {
if (taskId in prev) {
hasChanges = true;
break;
}
}
if (!hasChanges) return prev;
const newState: Record<string, InitState> = {};
for (const [id, state] of Object.entries(prev)) {
if (!removedTaskIds.includes(id)) {
newState[id] = state;
}
}
return newState;
});
}
// Mark removed pending initializations as cancelled even if EventSource not created yet
for (const taskId of Object.keys(cancelled)) {
if (!currentIds.has(taskId)) {
cancelled[taskId] = true;
initializing.delete(taskId);
delete pendingLiveEntriesRef.current[taskId];
delete loadingMoreRef.current[taskId];
delete resyncInFlightRef.current[taskId];
delete resyncPendingRef.current[taskId];
delete trimmedLiveTailRef.current[taskId];
}
}
// Initialize connections for current tasks
for (const taskId of taskIds) {
// Skip if already connected or currently initializing
if (subs[taskId] || initializing.has(taskId)) continue;
initializing.add(taskId);
cancelled[taskId] = false;
pendingLiveEntriesRef.current[taskId] = [];
resyncPendingRef.current[taskId] = [];
resyncInFlightRef.current[taskId] = false;
trimmedLiveTailRef.current[taskId] = false;
// Build SSE URL with optional projectId for multi-project support
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const isStale = () =>
cancelled[taskId] || projectContextVersionRef.current !== contextVersionAtStart;
/*
FNXC:AgentLogResync 2026-07-26-16:58:
Missed-event recovery. The per-task log stream replays nothing on connect, so any SSE gap (error
reconnect, or the mobile hidden-tab suspend) drops every entry emitted while the socket was down
and used to leave a silent hole in a transcript that still rendered as contiguous.
On reopen, refetch the authoritative newest page and RECONCILE it with the buffer instead of
replacing the buffer outright: replacing threw away paged-back history and could still hide a
gap larger than one page behind a seamless-looking list. reconcileReconnectedEntries splices
when the two windows provably overlap and otherwise resyncs behind a VISIBLE gap marker, so the
UI never implies continuity it cannot guarantee.
A trim during the merge means older entries are still server-side, which forces `hasMore` on.
If the refetch itself fails the buffer is left alone and the parked live events are flushed — a
degraded live tail beats dropping lines on the floor.
*/
const resyncTaskLogs = () => {
if (isStale() || resyncInFlightRef.current[taskId]) return;
resyncInFlightRef.current[taskId] = true;
resyncPendingRef.current[taskId] = [];
let reconciled = false;
void fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT })
.then((result) => {
if (isStale()) return;
const pending = resyncPendingRef.current[taskId] ?? [];
resyncPendingRef.current[taskId] = [];
reconciled = true;
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
const outcome = reconcileReconnectedEntries(current.entries, result.entries, pending, taskId);
// Idempotent ref write: React may double-invoke this updater in StrictMode.
if (outcome.trimmed) trimmedLiveTailRef.current[taskId] = true;
return {
...prev,
[taskId]: {
...current,
entries: outcome.entries,
hasMore: result.hasMore,
total: result.total,
},
};
});
// An offset-0 page reporting hasMore:false means the whole log fits in the page just
// merged, so nothing older is left behind.
if (!result.hasMore) trimmedLiveTailRef.current[taskId] = false;
})
.catch(() => {
// Keep the current entries on failure; the next reopen retries.
})
.finally(() => {
resyncInFlightRef.current[taskId] = false;
if (!reconciled && !isStale()) {
const pending = resyncPendingRef.current[taskId] ?? [];
resyncPendingRef.current[taskId] = [];
if (pending.length > 0) {
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
return {
...prev,
[taskId]: { ...current, entries: appendWithoutDuplicates(current.entries, pending) },
};
});
}
}
});
};
subs[taskId] = subscribeSse(
`/api/tasks/${taskId}/logs/stream${query}`,
{
onReconnect: resyncTaskLogs,
events: {
"agent:log": (e) => {
if (isStale()) return;
try {
const entry: AgentLogEntry = JSON.parse(e.data);
/*
FNXC:AgentLogResync 2026-07-26-17:02:
While a reconnect refetch is in flight the buffer must not move: park the event and
let the reconciliation append it after the authoritative page (deduped). `total` is
left alone here because the resync sets the authoritative count.
*/
if (resyncInFlightRef.current[taskId]) {
resyncPendingRef.current[taskId] = capLogEntries([
...(resyncPendingRef.current[taskId] ?? []),
entry,
]);
return;
}
pendingLiveEntriesRef.current[taskId] = capLogEntries([
...(pendingLiveEntriesRef.current[taskId] ?? []),
entry,
]);
setStateMap((prev) => {
const current = prev[taskId];
if (!current) return prev;
const appended = appendLiveEntry(current.entries, entry);
// Idempotent ref write: React may double-invoke this updater in StrictMode.
if (appended.trimmed) trimmedLiveTailRef.current[taskId] = true;
return {
...prev,
[taskId]: {
...current,
entries: appended.entries,
total: current.total !== null ? current.total + 1 : null,
},
};
});
} catch {
// skip malformed events
}
},
},
},
);
// Fetch historical logs with projectId using pagination
void fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT })
.then((result) => {
// Reject stale response from previous context
if (isStale()) return;
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
if (!result.hasMore) trimmedLiveTailRef.current[taskId] = false;
setStateMap((prev) => ({
...prev,
[taskId]: {
...prev[taskId],
entries: capLogEntries([...result.entries, ...pendingLive]),
loading: false,
hasMore: result.hasMore,
total: result.total,
},
}));
})
.catch(() => {
// Reject stale error from previous context
if (isStale()) return;
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
setStateMap((prev) => ({
...prev,
[taskId]: {
...prev[taskId],
entries: capLogEntries(pendingLive),
loading: false,
hasMore: false,
total: null,
},
}));
})
.finally(() => {
pendingLiveEntriesRef.current[taskId] = [];
initializingRef.current.delete(taskId);
});
}
// Update previous task IDs ref for cleanup comparison
const initialTaskIds = [...taskIds];
// Cleanup on effect re-run or unmount
return () => {
// Only drop subscriptions for tasks that were removed (not-in current taskIds)
for (const taskId of initialTaskIds) {
if (!currentIds.has(taskId)) {
cancelledRef.current[taskId] = true;
const unsub = unsubscribesRef.current[taskId];
if (unsub) {
unsub();
delete unsubscribesRef.current[taskId];
}
initializingRef.current.delete(taskId);
}
}
};
}, [stableKey]); // Use stable key including projectId
// Drop all subscriptions on unmount
useEffect(() => {
return () => {
for (const taskId of Object.keys(cancelledRef.current)) {
cancelledRef.current[taskId] = true;
}
for (const unsub of Object.values(unsubscribesRef.current)) {
unsub();
}
unsubscribesRef.current = {};
initializingRef.current.clear();
cancelledRef.current = {};
pendingLiveEntriesRef.current = {};
loadingMoreRef.current = {};
resyncInFlightRef.current = {};
resyncPendingRef.current = {};
trimmedLiveTailRef.current = {};
};
}, []);
// Build result map
const result: LogStateMap = {};
for (const taskId of taskIds) {
const state = stateMap[taskId];
const entries = state?.entries ?? [];
result[taskId] = {
entries,
loading: state?.loading ?? true,
loadingMore: state?.loadingMore ?? false,
/*
FNXC:AgentLogPaging 2026-07-26-17:08:
A trimmed live tail, or a reconnect gap marker at the head, means older entries are still on
the server, so the "load older" affordance must stay reachable even when the last fetch said
otherwise. Both signals are retired by the response that proves the buffer reaches entry 0, so
`hasMore` is true iff older entries actually remain.
*/
hasMore:
(state?.hasMore ?? false)
|| Boolean(trimmedLiveTailRef.current[taskId])
|| countLeadingGapMarkers(entries) > 0,
total: state?.total ?? null,
clear: createClearFn(taskId),
loadMore: createLoadMoreFn(taskId, entries),
};
}
return result;
}