Files
fusion/packages/dashboard/app/hooks/logStreamReconcile.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

338 lines
16 KiB
TypeScript

import type { AgentLogEntry } from "@fusion/core";
/*
FNXC:AgentLogResync 2026-07-26-16:05:
Shared reconciliation for LIVE-ONLY agent-log SSE channels.
`/api/tasks/:id/logs/stream` pipes `agent:log` events as they happen: no ring buffer, no
Last-Event-ID replay (packages/dashboard/src/server.ts). Everything the server emitted while the
channel was down is therefore unrecoverable from the socket, and the mobile hidden-tab SSE suspend
makes that window minutes long. Any hook that tails this channel must refetch the authoritative page
on reconnect and either PROVE the splice or render a VISIBLE gap — implied continuity is data loss
the reader cannot see.
This module exists because that fix landed in `useAgentLogs` only, while `useMultiAgentLogs`
subscribed to the same channel with a blunt "replace the buffer" resync. A hand-copied reconcile is
how this defect class survived into a third round (AGENTS.md "Reuse Components ... (No Drift)"), so
both hooks now import ONE implementation and cannot diverge.
*/
/*
FNXC:MobileTabRetention 2026-07-26-10:20:
Mobile browsers (iOS Safari tabs, iOS PWAs, Chrome Android) discard a backgrounded page whose
resident set is large, which the operator sees as a full white-splash reload on return. Every live
log tail must therefore be a bounded ring, never an array that grows for the lifetime of the session:
an agent streaming for an hour otherwise pins tens of MB of log entries per open surface.
500 is the one number shared by every log surface (agent detail, multi-agent, dev server) so the
per-surface memory ceiling stays predictable.
*/
export const MAX_LOG_ENTRIES = 500;
/**
* Keep only the newest `cap` items of a streaming buffer.
*
* Whole-list cap: it bounds how many entries are retained, never the content of an individual
* entry. Newest-wins — a log tail is read from the bottom, so dropping the oldest entries is the
* only truncation that preserves what the reader is actually looking at.
*
* Generic so the agent-detail, multi-agent, and command-center streams share this one
* implementation instead of each re-deriving the same `slice(-N)`.
*/
export function capLogEntries<T>(entries: T[], cap: number = MAX_LOG_ENTRIES): T[] {
return entries.length > cap ? entries.slice(-cap) : entries;
}
const LOG_GAP_MARKER_FLAG = "__fusionLogGap";
export const LOG_GAP_MARKER_TEXT =
"Log stream reconnected. Output emitted while this view was disconnected is not shown above; use \"load older\" to fetch it.";
/** A synthetic, client-only entry marking a proven discontinuity in the rendered log. */
export type AgentLogGapMarker = AgentLogEntry & { readonly [LOG_GAP_MARKER_FLAG]: true };
/**
* True for the synthetic gap marker. Renderers use it to style the break; the hooks use it to keep
* the marker out of every count that maps onto server-side offsets.
*/
export function isLogGapMarker(entry: AgentLogEntry): boolean {
return (entry as Partial<AgentLogGapMarker>)[LOG_GAP_MARKER_FLAG] === true;
}
export function createLogGapMarker(taskId: string, timestamp: string): AgentLogGapMarker {
return { timestamp, taskId, text: LOG_GAP_MARKER_TEXT, type: "status", [LOG_GAP_MARKER_FLAG]: true };
}
/** 1 when the buffer carries a leading gap marker, else 0. Every server-offset sum uses this. */
export function countLeadingGapMarkers(entries: readonly AgentLogEntry[]): number {
return entries.length > 0 && isLogGapMarker(entries[0]) ? 1 : 0;
}
/** True when the buffer carries a gap marker ANYWHERE, not only at the head. */
export function hasLogGapMarker(entries: readonly AgentLogEntry[]): boolean {
return entries.some(isLogGapMarker);
}
/*
FNXC:AgentLogPaging 2026-07-26-18:05:
Server `offset` counts back from the NEWEST entry, so the only sound offset is the size of the
newest CONTIGUOUS run the client holds — the entries below the last gap marker. `entries.length -
countLeadingGapMarkers(entries)` was only correct while a marker could exist at index 0 alone; with
`preservePagedHistory` a marker sits between the retained history and the refetched page, and
counting the entries above it would page past the gap and prepend the wrong window at the top.
*/
export function countNewestContiguousEntries(entries: readonly AgentLogEntry[]): number {
let count = 0;
for (let index = entries.length - 1; index >= 0; index--) {
if (isLogGapMarker(entries[index])) break;
count++;
}
return count;
}
function lastGapMarkerIndex(entries: readonly AgentLogEntry[]): number {
for (let index = entries.length - 1; index >= 0; index--) {
if (isLogGapMarker(entries[index])) return index;
}
return -1;
}
/**
* Identity of a log entry for de-duplication. Agent log rows carry no server id, so the full
* persisted content is the only available key; `timestamp` alone is not unique (streamed deltas
* share a millisecond).
*
* FNXC:DashboardLogs 2026-07-26-10:15:
* The separator MUST stay written as the LOG_ENTRY_KEY_SEPARATOR constant, never as a raw NUL byte in the source.
* A literal NUL makes git classify this file as binary (`git diff` prints "Bin", so the file becomes
* undiffable and unreviewable) and makes plain `grep` skip it entirely — both were observed here.
* The runtime value is identical; only the on-disk encoding differs.
*/
const LOG_ENTRY_KEY_SEPARATOR = String.fromCharCode(0);
function logEntryKey(entry: AgentLogEntry): string {
return [entry.timestamp, entry.type, entry.text, entry.detail ?? "", entry.agent ?? ""].join(LOG_ENTRY_KEY_SEPARATOR);
}
/**
* Length of the longest suffix of `prev` that is also a prefix of `next`, i.e. how much of `next`
* the caller already holds. 0 means the two windows do not provably touch.
*
* The LARGEST such overlap is chosen deliberately: with repeated identical lines several alignments
* can match, and the largest one is the only choice that cannot duplicate entries (it can at worst
* treat a repeat as already-held, which the next event corrects).
*/
export function findLogWindowOverlap(prev: AgentLogEntry[], next: AgentLogEntry[]): number {
const max = Math.min(prev.length, next.length);
if (max === 0) return 0;
const prevKeys = prev.slice(prev.length - max).map(logEntryKey);
const nextKeys = next.slice(0, max).map(logEntryKey);
for (let k = max; k > 0; k--) {
let matches = true;
for (let i = 0; i < k; i++) {
if (prevKeys[max - k + i] !== nextKeys[i]) {
matches = false;
break;
}
}
if (matches) return k;
}
return 0;
}
/** Append `next` after `prev`, dropping the leading entries of `next` that `prev` already holds. */
export function appendWithoutDuplicates(prev: AgentLogEntry[], next: AgentLogEntry[]): AgentLogEntry[] {
if (next.length === 0) return prev;
if (prev.length === 0) return next;
const overlap = findLogWindowOverlap(prev, next);
return [...prev.slice(0, prev.length - overlap), ...next];
}
/*
FNXC:AgentLogResync 2026-07-26-16:12:
Live-tail append with the ring ceiling applied.
Ceiling is `max(MAX_LOG_ENTRIES, prev.length)`: streaming holds the buffer at whatever size it has
(dropping one oldest entry per new line) instead of collapsing a transcript the reader deliberately
expanded with loadMore() straight back down to the cap.
A leading gap marker is client-only and is re-attached after the trim: dropping it would delete the
only visible evidence that entries are missing while the entries stay missing — the same "implied
continuity" failure this module exists to prevent (the reconcile path already re-attaches it after
its own cap; the live path used to not, which is the drift this shared helper closes).
`trimmed` is returned rather than mutated into a ref so the caller owns the "older entries still
exist server-side" signal that forces `hasMore` on.
*/
export function appendLiveEntry(
prev: AgentLogEntry[],
entry: AgentLogEntry,
): { entries: AgentLogEntry[]; trimmed: boolean } {
const limit = Math.max(MAX_LOG_ENTRIES, prev.length);
if (prev.length + 1 <= limit) return { entries: [...prev, entry], trimmed: false };
const gapMarkerCount = countLeadingGapMarkers(prev);
const marker = gapMarkerCount === 1 ? (prev[0] as AgentLogGapMarker) : null;
const real = gapMarkerCount === 1 ? prev.slice(1) : prev;
const realLimit = Math.max(1, limit - gapMarkerCount);
const kept = [...real.slice(real.length + 1 - realLimit), entry];
return { entries: marker ? [marker, ...kept] : kept, trimmed: true };
}
/*
FNXC:AgentLogResync 2026-07-26-18:10:
Bulk sibling of appendLiveEntry for flushing a batch of parked live events (the reconnect-refetch
holding buffer). Two deliberate differences from the single-entry path:
1. It de-duplicates against the buffer tail, because a parked batch can overlap entries an
authoritative page already merged; the single-entry path must NOT dedupe, since two identical
consecutive log lines are legitimate content.
2. It applies the same ring ceiling. The previous flush paths appended a raw array with no cap, so
a verbose agent on a slow reconnect could push the buffer arbitrarily past MAX_LOG_ENTRIES —
the exact unbounded growth the mobile-retention cap exists to prevent.
A leading gap marker is re-attached after the trim for the same reason as appendLiveEntry.
*/
export function appendLiveEntries(
prev: AgentLogEntry[],
incoming: AgentLogEntry[],
): { entries: AgentLogEntry[]; trimmed: boolean } {
if (incoming.length === 0) return { entries: prev, trimmed: false };
const merged = appendWithoutDuplicates(prev, incoming);
const limit = Math.max(MAX_LOG_ENTRIES, prev.length);
if (merged.length <= limit) return { entries: merged, trimmed: false };
const gapMarkerCount = countLeadingGapMarkers(merged);
const marker = gapMarkerCount === 1 ? (merged[0] as AgentLogGapMarker) : null;
const real = gapMarkerCount === 1 ? merged.slice(1) : merged;
const realLimit = Math.max(1, limit - gapMarkerCount);
const kept = real.slice(real.length - realLimit);
return { entries: marker ? [marker, ...kept] : kept, trimmed: true };
}
/*
FNXC:AgentLogPaging 2026-07-26-18:18:
Merge one "load older" page into the buffer. Replaces the hand-rolled prepend that each log hook
carried, which had three silent-incorrectness defects:
1. Blind concatenation. The server resolves `offset` against its CURRENT total, so any entry
persisted between the client reading its offset and the server reading the log shifts the
returned window N entries newer. Concatenating it duplicated N entries at the seam and (before
dedup) implied adjacency that did not hold. `appendWithoutDuplicates(page, block)` drops exactly
the page tail the buffer already holds, which makes the splice correct for any shift smaller
than a page; a shift of a whole page or more degenerates to a no-op fetch, never a hole.
2. Retiring a gap marker on ANY non-empty page. A leading marker means an unbounded amount of
output is missing ABOVE the buffer; one 100-entry page proves nothing about the rest of it.
The marker is retired only on proof: the page reaching entry 0 (`serverHasMore === false`), or
the page overlapping the retained block above the marker.
3. Paging from the wrong end. With history retained above a gap marker, the page belongs directly
ABOVE the newest contiguous block (i.e. immediately below the marker), so each "load older"
shrinks the gap from the bottom until it overlaps the retained history and the marker retires.
*/
export function mergeOlderPage(
prev: AgentLogEntry[],
page: AgentLogEntry[],
options: { serverHasMore: boolean },
): AgentLogEntry[] {
const markerIndex = lastGapMarkerIndex(prev);
if (markerIndex === -1) return appendWithoutDuplicates(page, prev);
const marker = prev[markerIndex];
const above = prev.slice(0, markerIndex);
const below = appendWithoutDuplicates(page, prev.slice(markerIndex + 1));
if (above.length === 0) {
// Nothing retained above the gap: only reaching entry 0 disproves "output is missing above".
if (!options.serverHasMore) return below;
return page.length > 0 ? [marker, ...below] : prev;
}
const aboveBlock = above.slice(lastGapMarkerIndex(above) + 1);
const overlap = page.length > 0 ? findLogWindowOverlap(aboveBlock, page) : 0;
if (overlap > 0) return [...above.slice(0, above.length - overlap), ...below];
return [...above, marker, ...below];
}
/*
FNXC:AgentLogResync 2026-07-26-14:12:
Reconnect reconciliation. Inputs: the buffer as rendered (`prev`, which may already carry a leading
gap marker), the authoritative newest page (`fresh`), and any live events that arrived while the
refetch was in flight (`pending`, held out of `prev` so the merge sees a stable snapshot).
Overlap case: the reader's buffer and the refetched page share entries, so the missed lines are
exactly `fresh`'s non-overlapping tail — splice and keep the whole paged-back history.
No-overlap case: more than one page was missed, so the two windows cannot be proven adjacent and a
gap marker is required.
FNXC:AgentLogResync 2026-07-26-18:26:
CORRECTION to the note that stood here: it claimed the older prefix HAD to be dropped because
keeping it would break `loadMore`'s offset arithmetic. That reasoning was wrong, and the behaviour it
justified destroyed data the reader had explicitly fetched — five "load older" clicks (~600 entries)
were replaced by the newest 100 whenever a hidden-tab resync missed more than a page. Offsets are
sound with the history retained as long as paging counts only the newest CONTIGUOUS block
(`countNewestContiguousEntries`) and merges through `mergeOlderPage`, which is what both now do.
`preservePagedHistory` therefore keeps the retained entries BELOW the marker, in place:
`[...history, gapMarker, ...fresh]`. It is opt-in only because the remaining callers of this helper
(useMultiAgentLogs, AgentDetailView) still compute offsets with `countLeadingGapMarkers` and must be
migrated to the contiguous-block count in the same change that flips them; until then they keep the
legacy head-marker shape rather than silently paging into the gap.
Cap: the merged buffer honours the same ring ceiling as the live tail (`max(MAX_LOG_ENTRIES,
prev.length)`) so a resync cannot blow past the memory budget that the mobile-retention work exists
to enforce. The trim is a front trim, so a retained marker keeps its meaning: whatever it said was
missing above it is still missing, plus whatever the trim removed.
*/
export function reconcileReconnectedEntries(
prev: AgentLogEntry[],
fresh: AgentLogEntry[],
pending: AgentLogEntry[],
taskId: string,
options: { preservePagedHistory?: boolean } = {},
): { entries: AgentLogEntry[]; trimmed: boolean; gapInserted: boolean } {
const hadGapMarker = prev.length > 0 && isLogGapMarker(prev[0]);
const previousGapMarker = hadGapMarker ? (prev[0] as AgentLogGapMarker) : null;
const prevReal = hadGapMarker ? prev.slice(1) : prev;
let gapMarker: AgentLogGapMarker | null = previousGapMarker;
let gapInserted = false;
let merged: AgentLogEntry[];
if (fresh.length === 0) {
merged = prevReal;
} else if (prevReal.length === 0) {
merged = fresh;
} else {
const overlap = findLogWindowOverlap(prevReal, fresh);
if (overlap > 0) {
merged = [...prevReal.slice(0, prevReal.length - overlap), ...fresh];
} else {
const marker = createLogGapMarker(taskId, fresh[0]?.timestamp ?? new Date(0).toISOString());
gapInserted = true;
if (options.preservePagedHistory) {
merged = [...prevReal, marker, ...fresh];
} else {
merged = fresh;
gapMarker = marker;
}
}
}
merged = appendWithoutDuplicates(merged, pending);
/*
FNXC:AgentLogResync 2026-07-26-18:34:
The ceiling is unchanged by `preservePagedHistory`: retaining history must not let one resync grow
the resident set by a whole page on every reconnect. Consequence to be honest about — a
history-preserving merge whose result exceeds `max(MAX_LOG_ENTRIES, prev.length)` still front-trims
the OLDEST retained entries. That is the ring policy (newest-wins), it is reported through
`trimmed` -> `hasMore`, and it removes strictly less than the previous behaviour, which dropped the
entire retained history.
*/
const limit = Math.max(MAX_LOG_ENTRIES, prev.length);
const trimmed = merged.length > limit;
if (trimmed) merged = merged.slice(merged.length - limit);
return {
entries: gapMarker ? [gapMarker, ...merged] : merged,
trimmed,
gapInserted,
};
}