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>
571 lines
26 KiB
TypeScript
571 lines
26 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 { recordResumeEvent } from "../utils/resumeInstrumentation";
|
|
import {
|
|
MAX_LOG_ENTRIES,
|
|
appendLiveEntries,
|
|
appendLiveEntry,
|
|
countNewestContiguousEntries,
|
|
hasLogGapMarker,
|
|
mergeOlderPage,
|
|
reconcileReconnectedEntries,
|
|
} from "./logStreamReconcile";
|
|
|
|
const INITIAL_LOAD_LIMIT = 100;
|
|
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-18:42:
|
|
Ceiling on live events parked while a reconnect refetch is in flight. `fetchAgentLogsWithMeta`
|
|
accepts no AbortSignal (it lives in ../api, and widening that signature is out of this change's
|
|
scope), so a resync on a waking radio can stay outstanding for a long time while a verbose agent
|
|
streams. Parked events sit OUTSIDE the ring buffer, so without this bound they are exactly the
|
|
unbounded array MAX_LOG_ENTRIES was introduced to eliminate.
|
|
|
|
On overflow the parking is ABANDONED rather than the events dropped: the in-flight resync's result is
|
|
discarded by generation, the parked batch is flushed into the ring (bounded, newest-wins, `hasMore`
|
|
signals the trim), live events resume streaming straight into the buffer, and a fresh resync is
|
|
re-run once the abandoned fetch settles. Nothing is silently lost that the ring itself would not have
|
|
dropped anyway.
|
|
|
|
RESYNC_TIMEOUT_MS bounds the resync STATE, not the HTTP request: with no signal to pass, the timeout
|
|
races the promise so the hook stops parking events and re-runs, while the underlying fetch is left to
|
|
settle on its own and is then discarded by generation. Do not describe this as request cancellation.
|
|
*/
|
|
const MAX_PENDING_LIVE_ENTRIES = MAX_LOG_ENTRIES;
|
|
const RESYNC_TIMEOUT_MS = 15_000;
|
|
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-16:20:
|
|
Buffer bounding, gap marking, and reconnect reconciliation now live in ./logStreamReconcile so this
|
|
hook and useMultiAgentLogs share ONE implementation. Both tail the same live-only
|
|
`/api/tasks/:id/logs/stream`, and a hand-copied reconcile is how the silent-suspend-gap defect
|
|
survived into a third round. Re-exported here because existing call sites (AgentDetailView,
|
|
SystemControlsArea, TaskChatTab, tests) import these names from this module.
|
|
*/
|
|
export {
|
|
MAX_LOG_ENTRIES,
|
|
capLogEntries,
|
|
isLogGapMarker,
|
|
findLogWindowOverlap,
|
|
reconcileReconnectedEntries,
|
|
LOG_GAP_MARKER_TEXT,
|
|
} from "./logStreamReconcile";
|
|
export type { AgentLogGapMarker } from "./logStreamReconcile";
|
|
|
|
function getActiveContextKey(taskId: string | null, enabled: boolean, projectId?: string): string | null {
|
|
if (!taskId || !enabled) return null;
|
|
return `${projectId ?? ""}\u0000${taskId}`;
|
|
}
|
|
|
|
/**
|
|
* Hook that manages agent log fetching and live SSE streaming for a task.
|
|
*
|
|
* Features:
|
|
* - **Pagination**: Initial load fetches 100 entries. Use `loadMore()` to fetch older entries.
|
|
* - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
|
|
* - **Live streaming**: SSE events append new entries to the end of the list.
|
|
*
|
|
* **Pagination semantics**:
|
|
* - Entries are returned in chronological order (oldest first) from the API
|
|
* - Entries are stored in chronological order
|
|
* - The UI displays entries in chronological order (oldest first)
|
|
* - `loadMore()` fetches the next 100 older entries and prepends them
|
|
*
|
|
* When `enabled` is true:
|
|
* 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 `enabled` becomes false or the component unmounts, the EventSource
|
|
* is closed to avoid unnecessary SSE connections.
|
|
*
|
|
* **Reconnect semantics**: the stream replays nothing on open, so every reconnect (SSE error,
|
|
* heartbeat timeout, or the hidden-tab suspend/resume) refetches the authoritative newest page and
|
|
* reconciles it with the buffer. Where reconciliation cannot be proven, a gap marker is rendered
|
|
* (`isLogGapMarker`) rather than implying continuity.
|
|
*
|
|
* @returns Object with entries, loading, clear, loadMore, hasMore, total
|
|
*/
|
|
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
|
|
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [total, setTotal] = useState<number | null>(null);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [loadedContextKey, setLoadedContextKey] = useState<string | null>(null);
|
|
|
|
// Refs for state that needs to survive re-renders
|
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
const cancelledRef = useRef(false);
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-14:20:
|
|
"The live tail dropped older entries" flag; see the SSE handler below. It is sticky ONLY until
|
|
paging proves otherwise — a previous version cleared it only on clear()/context switch, so once the
|
|
tail had trimmed, `hasMore` stayed true even after the reader paged all the way back to entry 0.
|
|
The "load older" control then rendered forever and every further click fetched past the end and
|
|
changed nothing, leaving the reader unable to tell "this is the beginning" from "this is broken".
|
|
Any authoritative fetch that reports `hasMore:false` proves the buffer now reaches entry 0
|
|
(server-side `hasMore = total > offset + returned`), so that response clears the flag.
|
|
*/
|
|
const trimmedLiveTailRef = useRef(false);
|
|
// A reconnect refetch is in flight; live events are parked in pendingLiveRef so the merge sees a
|
|
// stable `prev` snapshot and cannot duplicate or drop lines that race the fetch.
|
|
const resyncInFlightRef = useRef(false);
|
|
const pendingLiveRef = useRef<AgentLogEntry[]>([]);
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-18:48:
|
|
`resyncInFlightRef` single-flights resyncs, which used to mean a reconnect arriving DURING a resync
|
|
was silently discarded. forceReconnect fires onReconnect at teardown and again ~RECONNECT_DELAY_MS
|
|
later when the socket reopens; if the first refetch outlived that delay, the second reconnect hit
|
|
the early return, the lines emitted between the first fetch's snapshot and the socket reopening were
|
|
never fetched and never streamed, and the merge produced a buffer with NO gap marker — a real hole
|
|
rendered as contiguous output. A superseded reconnect now sets `resyncRerunRef` and the resync
|
|
re-runs exactly once after the in-flight one settles.
|
|
|
|
`resyncGenerationRef` is the discard token: an abandoned resync (pending-buffer overflow or the
|
|
settle timeout) bumps it, so the late result cannot be merged into a buffer that has moved on and
|
|
cannot clear the in-flight flag of the resync that replaced it.
|
|
*/
|
|
const resyncRerunRef = useRef(false);
|
|
const resyncGenerationRef = useRef(0);
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-18:52:
|
|
`loadMore` captures its offset from the rendered buffer and fetches at that offset. A resync that
|
|
lands while that fetch is in flight REPLACES the buffer, after which prepending the offset page
|
|
produces `[entries 500-600][newest 100]` — a hole with no marker, and every later offset wrong.
|
|
Every buffer replacement bumps this version; a loadMore whose base version changed discards its
|
|
page instead of splicing it. Bumped by: context change, clear, a resync merge, a parked-event flush,
|
|
and a live append that TRIMS (a front trim removes exactly the entries the in-flight page would be
|
|
spliced against). A non-trimming live append deliberately does NOT bump it: it shifts the server
|
|
offset, but `mergeOlderPage` de-duplicates that shift, so invalidating on every streamed line would
|
|
make "load older" unusable on a busy stream for no correctness gain.
|
|
*/
|
|
const bufferVersionRef = useRef(0);
|
|
|
|
// Track the project context version to detect stale SSE events after project switches.
|
|
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
|
|
const projectContextVersionRef = useRef(0);
|
|
|
|
// Track previous values to detect context changes
|
|
const previousTaskIdRef = useRef<string | null>(taskId);
|
|
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
|
const previousEnabledRef = useRef(enabled);
|
|
|
|
// Track request version to reject stale fetch completions
|
|
const requestVersionRef = useRef(0);
|
|
|
|
// Detect context changes and clear state immediately
|
|
const activeContextKey = getActiveContextKey(taskId, enabled, projectId);
|
|
const contextChanged =
|
|
previousTaskIdRef.current !== taskId ||
|
|
previousProjectIdRef.current !== projectId ||
|
|
previousEnabledRef.current !== enabled;
|
|
|
|
if (contextChanged) {
|
|
previousTaskIdRef.current = taskId;
|
|
previousProjectIdRef.current = projectId;
|
|
previousEnabledRef.current = enabled;
|
|
projectContextVersionRef.current++;
|
|
recordResumeEvent({
|
|
view: "useAgentLogs",
|
|
trigger: "project-context-change",
|
|
projectId,
|
|
replayAttempted: false,
|
|
reason: "context-version-bumped",
|
|
detail: { taskId },
|
|
});
|
|
cancelledRef.current = true;
|
|
|
|
// Clear entries immediately on context change to prevent stale data visibility
|
|
trimmedLiveTailRef.current = false;
|
|
resyncInFlightRef.current = false;
|
|
resyncRerunRef.current = false;
|
|
resyncGenerationRef.current++;
|
|
pendingLiveRef.current = [];
|
|
bufferVersionRef.current++;
|
|
setEntries([]);
|
|
setLoading(false);
|
|
setHasMore(false);
|
|
setTotal(null);
|
|
setLoadingMore(false);
|
|
setLoadedContextKey(null);
|
|
|
|
// Drop existing SSE subscription
|
|
if (unsubscribeRef.current) {
|
|
unsubscribeRef.current();
|
|
unsubscribeRef.current = null;
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!taskId || !enabled) {
|
|
// Drop any existing subscription when disabled
|
|
if (unsubscribeRef.current) {
|
|
unsubscribeRef.current();
|
|
unsubscribeRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Capture context version at effect start - stale SSE events will be rejected
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const requestVersion = ++requestVersionRef.current;
|
|
cancelledRef.current = false;
|
|
|
|
// Capture taskId and projectId at effect start for comparison
|
|
const currentTaskId = taskId;
|
|
const currentProjectId = projectId;
|
|
const requestContextKey = getActiveContextKey(currentTaskId, true, currentProjectId);
|
|
|
|
const isStale = () =>
|
|
cancelledRef.current || projectContextVersionRef.current !== contextVersionAtStart;
|
|
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-14:26:
|
|
Reconnect handler. The log stream replays nothing, so recovery has to come from the REST path.
|
|
Refetch the newest page at offset 0 and reconcile it with what the reader already holds; a single
|
|
resync at a time (a burst of reconnects must not stack refetches). Live events that land while
|
|
the fetch is in flight are parked, then appended after the merge, so the reconnect window cannot
|
|
duplicate or swallow them. If the refetch itself fails the buffer is left alone and the parked
|
|
events are flushed — a degraded live tail is still better than dropping lines on the floor.
|
|
*/
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-18:58:
|
|
Abandon the in-flight resync: discard its result by generation, stop parking live events, and
|
|
flush what is already parked into the ring. Used by the pending-buffer overflow and the settle
|
|
timeout. It does NOT itself re-run — the abandoned fetch's `finally` does, once it settles, so a
|
|
hung request cannot fan out into concurrent refetches.
|
|
*/
|
|
function abandonResync(): AgentLogEntry[] {
|
|
resyncGenerationRef.current++;
|
|
resyncInFlightRef.current = false;
|
|
resyncRerunRef.current = true;
|
|
const parked = pendingLiveRef.current;
|
|
pendingLiveRef.current = [];
|
|
return parked;
|
|
}
|
|
|
|
function flushParkedEntries(parked: AgentLogEntry[]) {
|
|
if (parked.length === 0) return;
|
|
bufferVersionRef.current++;
|
|
setEntries((prev) => {
|
|
const appended = appendLiveEntries(prev, parked);
|
|
if (appended.trimmed) trimmedLiveTailRef.current = true;
|
|
return appended.entries;
|
|
});
|
|
}
|
|
|
|
async function resyncFromServer() {
|
|
if (!currentTaskId || isStale()) return;
|
|
if (resyncInFlightRef.current) {
|
|
// Superseded reconnect: remember it so the missed window is fetched once this one settles.
|
|
resyncRerunRef.current = true;
|
|
return;
|
|
}
|
|
const resyncTaskId: string = currentTaskId;
|
|
const generation = ++resyncGenerationRef.current;
|
|
resyncInFlightRef.current = true;
|
|
pendingLiveRef.current = [];
|
|
let reconciled = false;
|
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
const result = await Promise.race([
|
|
fetchAgentLogsWithMeta(resyncTaskId, currentProjectId, { limit: INITIAL_LOAD_LIMIT }),
|
|
new Promise<never>((_resolve, reject) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
flushParkedEntries(abandonResync());
|
|
reject(new Error("agent-log resync timed out"));
|
|
}, RESYNC_TIMEOUT_MS);
|
|
}),
|
|
]);
|
|
// A superseded/abandoned resync must not merge a snapshot the buffer has moved past.
|
|
if (isStale() || resyncGenerationRef.current !== generation) return;
|
|
|
|
const pending = pendingLiveRef.current;
|
|
pendingLiveRef.current = [];
|
|
reconciled = true;
|
|
bufferVersionRef.current++;
|
|
setEntries((prev) => {
|
|
const outcome = reconcileReconnectedEntries(prev, result.entries, pending, resyncTaskId, {
|
|
preservePagedHistory: true,
|
|
});
|
|
if (outcome.trimmed) trimmedLiveTailRef.current = true;
|
|
return outcome.entries;
|
|
});
|
|
setTotal(result.total);
|
|
setHasMore(result.hasMore);
|
|
// An offset-0 page reporting hasMore:false means the whole log fits in the page we just
|
|
// merged, so nothing older is left behind — see the trimmedLiveTailRef note above.
|
|
if (!result.hasMore) trimmedLiveTailRef.current = false;
|
|
} catch {
|
|
// Fall through: the live tail keeps running and the resync is retried below/on next reconnect.
|
|
} finally {
|
|
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
|
const current = resyncGenerationRef.current === generation;
|
|
if (current) {
|
|
resyncInFlightRef.current = false;
|
|
if (!reconciled && !isStale()) flushParkedEntries(pendingLiveRef.current.splice(0));
|
|
}
|
|
if (resyncRerunRef.current && !isStale()) {
|
|
resyncRerunRef.current = false;
|
|
void resyncFromServer();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
if (!currentTaskId) return;
|
|
|
|
setLoading(true);
|
|
setLoadingMore(false);
|
|
try {
|
|
const result = await fetchAgentLogsWithMeta(currentTaskId, currentProjectId, { limit: INITIAL_LOAD_LIMIT });
|
|
|
|
// Reject stale response: check context version and request version
|
|
if (cancelledRef.current ||
|
|
projectContextVersionRef.current !== contextVersionAtStart ||
|
|
requestVersionRef.current !== requestVersion) {
|
|
return;
|
|
}
|
|
setEntries(result.entries);
|
|
setHasMore(result.hasMore);
|
|
setTotal(result.total);
|
|
setLoadedContextKey(requestContextKey);
|
|
} catch {
|
|
// Reject stale error: check context version and request version
|
|
if (cancelledRef.current ||
|
|
projectContextVersionRef.current !== contextVersionAtStart ||
|
|
requestVersionRef.current !== requestVersion) {
|
|
return;
|
|
}
|
|
setEntries([]);
|
|
setHasMore(false);
|
|
setTotal(null);
|
|
setLoadedContextKey(requestContextKey);
|
|
} finally {
|
|
// Only update loading state if not cancelled and not stale
|
|
if (!cancelledRef.current &&
|
|
projectContextVersionRef.current === contextVersionAtStart &&
|
|
requestVersionRef.current === requestVersion) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
// Subscribe to the shared per-task log stream
|
|
const query = currentProjectId ? `?projectId=${encodeURIComponent(currentProjectId)}` : "";
|
|
unsubscribeRef.current = subscribeSse(
|
|
`/api/tasks/${currentTaskId}/logs/stream${query}`,
|
|
{
|
|
onOpen: () => {
|
|
recordResumeEvent({
|
|
view: "useAgentLogs",
|
|
trigger: "sse-open",
|
|
projectId: currentProjectId,
|
|
replayAttempted: false,
|
|
sseChannel: `/api/tasks/${currentTaskId}/logs/stream`,
|
|
detail: { taskId: currentTaskId },
|
|
});
|
|
},
|
|
onReconnect: () => {
|
|
recordResumeEvent({
|
|
view: "useAgentLogs",
|
|
trigger: "sse-reconnect",
|
|
projectId: currentProjectId,
|
|
replayAttempted: true,
|
|
reason: "refetch-authoritative-log-page",
|
|
sseChannel: `/api/tasks/${currentTaskId}/logs/stream`,
|
|
detail: { taskId: currentTaskId },
|
|
});
|
|
void resyncFromServer();
|
|
},
|
|
events: {
|
|
"agent:log": (e) => {
|
|
if (cancelledRef.current ||
|
|
projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
try {
|
|
const entry: AgentLogEntry = JSON.parse(e.data);
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-14:32:
|
|
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), otherwise a
|
|
line that is in both the page and the live stream would render twice — or, if the
|
|
merge ran against a moved buffer, be lost. `total` is left alone here because the
|
|
resync sets the authoritative count.
|
|
*/
|
|
if (resyncInFlightRef.current) {
|
|
if (pendingLiveRef.current.length < MAX_PENDING_LIVE_ENTRIES) {
|
|
pendingLiveRef.current.push(entry);
|
|
return;
|
|
}
|
|
/*
|
|
FNXC:AgentLogResync 2026-07-26-19:04:
|
|
Parking is bounded (MAX_PENDING_LIVE_ENTRIES). At the ceiling the resync is
|
|
abandoned instead of the buffer growing without limit: the parked batch is flushed
|
|
into the ring, this event falls through to the normal live append, and a fresh
|
|
resync runs once the abandoned fetch settles. Dropping the parked events instead
|
|
would put an unmarked hole in the middle of the rendered log.
|
|
*/
|
|
flushParkedEntries(abandonResync());
|
|
}
|
|
/*
|
|
FNXC:MobileTabRetention 2026-07-26-10:24:
|
|
The live tail is bounded so a long-running agent cannot grow this buffer without
|
|
limit (see MAX_LOG_ENTRIES). The 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 user deliberately expanded with
|
|
loadMore() straight back down to the cap.
|
|
A trim sets `trimmedLiveTailRef`, which forces `hasMore` on: older entries still
|
|
exist server-side and the viewer's "load older" affordance is the truncation signal,
|
|
so the reader is never shown a silently-clipped tail that looks complete. The flag is
|
|
a ref because the trim decision is only known inside the state updater, and it is
|
|
idempotent so React's double-invoked updaters cannot corrupt it.
|
|
*/
|
|
setEntries((prev) => {
|
|
const appended = appendLiveEntry(prev, entry);
|
|
if (appended.trimmed) {
|
|
trimmedLiveTailRef.current = true;
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-20:44:
|
|
A trimming append drops entries off the FRONT, which is exactly where an in-flight
|
|
loadMore page is about to be spliced: prepending it after the trim leaves the
|
|
trimmed entries missing between the page and the buffer, unmarked. So a trim — and
|
|
only a trim — invalidates the buffer version. A plain append does not: it shifts
|
|
the server offset, but mergeOlderPage de-duplicates that shift, and invalidating
|
|
every streamed line would make "load older" unusable on a busy stream.
|
|
*/
|
|
bufferVersionRef.current++;
|
|
}
|
|
return appended.entries;
|
|
});
|
|
setTotal((prev) => (prev !== null ? prev + 1 : null));
|
|
} catch {
|
|
// skip malformed events
|
|
}
|
|
},
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
void init();
|
|
|
|
return () => {
|
|
cancelledRef.current = true;
|
|
if (unsubscribeRef.current) {
|
|
unsubscribeRef.current();
|
|
unsubscribeRef.current = null;
|
|
}
|
|
};
|
|
}, [taskId, enabled, projectId]);
|
|
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-19:10:
|
|
CORRECTION to the note that stood here: it asserted the gap marker "always sits at index 0". That
|
|
is no longer true and the code that relied on it is gone — a history-preserving resync leaves the
|
|
marker BETWEEN the retained history and the refetched page. Two derived values replace the single
|
|
leading-marker count:
|
|
- `newestContiguousCount` is the only sound `loadMore` offset (entries below the last marker);
|
|
counting through a marker would page past the gap and splice the wrong window in at the top.
|
|
- `gapPresent` scans the whole buffer, because a gap anywhere means older output is still
|
|
fetchable and the "load older" affordance must stay reachable to fill it.
|
|
*/
|
|
const newestContiguousCount = countNewestContiguousEntries(entries);
|
|
const gapPresent = hasLogGapMarker(entries);
|
|
|
|
/**
|
|
* Load more older entries.
|
|
* Fetches the next 100 older entries and prepends them to the existing list.
|
|
*/
|
|
const loadMore = useCallback(async () => {
|
|
if (!taskId || loadingMore) return;
|
|
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const baseBufferVersion = bufferVersionRef.current;
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-19:14:
|
|
The server offset counts back from the newest entry, so it must be the number of REAL entries in
|
|
the newest CONTIGUOUS run. A synthetic gap marker is client-only, and entries retained above a
|
|
marker are not adjacent to the newest run, so counting either of them would fetch the wrong
|
|
window.
|
|
*/
|
|
const currentEntriesCount = newestContiguousCount;
|
|
const currentTaskId = taskId;
|
|
|
|
setLoadingMore(true);
|
|
try {
|
|
const result = await fetchAgentLogsWithMeta(currentTaskId, projectId, {
|
|
limit: INITIAL_LOAD_LIMIT,
|
|
offset: currentEntriesCount,
|
|
});
|
|
|
|
// Reject stale response
|
|
if (cancelledRef.current ||
|
|
projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-19:16:
|
|
A reconnect resync that landed while this page was in flight replaced the buffer, so this page
|
|
was fetched at an offset that no longer describes it. Splicing it anyway produced
|
|
`[entries 500-600][newest 100]` — an unmarked hole plus permanently wrong offsets. Discard it
|
|
instead: the resync already set authoritative `hasMore`/`total`, `hasMore` stays true, and the
|
|
reader's next "load older" pages from the corrected offset. A discarded page renders nothing
|
|
new; it never renders something wrong.
|
|
*/
|
|
if (bufferVersionRef.current !== baseBufferVersion) return;
|
|
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-19:18:
|
|
Merge through the shared `mergeOlderPage`, which de-duplicates the seam (the server resolves
|
|
`offset` against its CURRENT total, so entries persisted since the offset was read shift the
|
|
window), fills a gap from below, and retires the marker only on proof — page overlap with the
|
|
retained history, or the page reaching entry 0. The previous inline prepend retired the marker
|
|
on ANY non-empty page, which claimed a multi-page gap was closed after fetching 100 entries of
|
|
it.
|
|
*/
|
|
setEntries((prev) => mergeOlderPage(prev, result.entries, { serverHasMore: result.hasMore }));
|
|
bufferVersionRef.current++;
|
|
setHasMore(result.hasMore);
|
|
setTotal(result.total);
|
|
// Paging reached entry 0: the buffer is contiguous back to the beginning, so the live-tail
|
|
// trim no longer implies anything older remains.
|
|
if (!result.hasMore) trimmedLiveTailRef.current = false;
|
|
} catch {
|
|
// Silently fail on load more errors
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
}, [taskId, projectId, newestContiguousCount, loadingMore]);
|
|
|
|
const clear = useCallback(() => {
|
|
trimmedLiveTailRef.current = false;
|
|
pendingLiveRef.current = [];
|
|
bufferVersionRef.current++;
|
|
setEntries([]);
|
|
}, []);
|
|
const initialContextLoading = Boolean(activeContextKey && loadedContextKey !== activeContextKey);
|
|
|
|
return {
|
|
entries,
|
|
loading: loading || initialContextLoading,
|
|
clear,
|
|
loadMore,
|
|
/*
|
|
FNXC:AgentLogPaging 2026-07-26-19:24:
|
|
A trimmed live tail, or a reconnect gap marker ANYWHERE in the buffer, 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 proof: the trim flag by a response reaching entry 0,
|
|
the marker by `mergeOlderPage` splicing the gap closed. `hasMore` is true iff output the reader
|
|
cannot currently see is still fetchable.
|
|
*/
|
|
hasMore: hasMore || trimmedLiveTailRef.current || gapPresent,
|
|
total,
|
|
loadingMore,
|
|
};
|
|
}
|