From f26cbedf4f4c95faf44b49e777bcb4939e6c9b0f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 26 Jul 2026 11:17:52 -0700 Subject: [PATCH] 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 ?
: 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) --- .changeset/mobile-tab-discard-retention.md | 4 +- packages/dashboard/app/App.tsx | 16 + .../__tests__/component-sse-resync.test.tsx | 339 ++++++++++++ packages/dashboard/app/__tests__/pwa.test.ts | 512 +++++++++++++++++- .../sse-bus-visibility-suspend.test.ts | 173 ++++++ .../dashboard/app/__tests__/sse-bus.test.ts | 45 +- .../__tests__/sse-resync-on-reopen.test.ts | 95 +++- .../app/components/AgentDetailView.tsx | 80 ++- packages/dashboard/app/components/Column.tsx | 29 +- .../dashboard/app/components/ListView.tsx | 98 ++-- .../dashboard/app/components/MailboxModal.tsx | 10 + .../dashboard/app/components/MailboxView.tsx | 10 + .../dashboard/app/components/TaskChatTab.css | 20 + .../dashboard/app/components/TaskChatTab.tsx | 33 +- .../app/components/TaskDetailModal.tsx | 87 ++- .../app/components/WorkflowNodeEditor.tsx | 9 + .../app/components/__tests__/App.test.tsx | 128 ++++- .../components/__tests__/AppModals.test.tsx | 22 + .../app/components/__tests__/Column.test.tsx | 37 ++ .../TerminalModal.closed-mount-cost.test.tsx | 168 ++++++ .../TerminalModal.webgl-disposal.test.tsx | 372 +++++++++++++ .../agent-detail-log-history.test.tsx | 154 ++++++ .../__tests__/list-view-windowing.test.tsx | 90 ++- .../terminal-scrollback-floor.test.ts | 121 +++++ .../__tests__/MissionControlPanel.test.tsx | 108 ++++ .../areas/SystemControlsArea.tsx | 177 +++++- .../system-controls-job-stream.test.tsx | 315 +++++++++++ .../app/hooks/__tests__/resyncRetry.test.ts | 131 +++++ .../__tests__/sseSplitIntegration.test.ts | 15 +- .../hooks/__tests__/useActivityLog.test.ts | 78 +++ .../app/hooks/__tests__/useAgentLogs.test.ts | 372 ++++++++++++- .../useApprovalBanner.resync.test.ts | 155 ++++++ .../hooks/__tests__/useChat.resync.test.ts | 175 ++++++ .../app/hooks/__tests__/useChat.test.ts | 25 +- .../useLiveTranscript.resync.test.ts | 176 ++++++ .../hooks/__tests__/useMultiAgentLogs.test.ts | 269 +++++++++ .../useTasks-hydration-freshness.test.ts | 112 +++- .../useTasks-stale-hydration.test.ts | 51 +- .../app/hooks/__tests__/useTasks.test.ts | 63 ++- .../app/hooks/__tests__/useViewState.test.ts | 55 ++ .../__tests__/visibility-poll-gating.test.ts | 106 +++- .../dashboard/app/hooks/logStreamReconcile.ts | 337 ++++++++++++ packages/dashboard/app/hooks/resyncRetry.ts | 116 ++++ .../dashboard/app/hooks/useActivityLog.ts | 19 +- packages/dashboard/app/hooks/useAgentLogs.ts | 420 +++++++------- .../dashboard/app/hooks/useApprovalBanner.ts | 88 ++- packages/dashboard/app/hooks/useChat.ts | 145 +++-- .../dashboard/app/hooks/useLiveTimeTicker.ts | 33 +- .../dashboard/app/hooks/useLiveTranscript.ts | 114 +++- packages/dashboard/app/hooks/useMeshState.ts | 25 +- .../dashboard/app/hooks/useMultiAgentLogs.ts | 206 +++++-- packages/dashboard/app/hooks/useNodes.ts | 26 +- packages/dashboard/app/hooks/useProjects.ts | 30 +- packages/dashboard/app/hooks/useTasks.ts | 100 +++- packages/dashboard/app/hooks/useViewState.ts | 35 +- .../app/hooks/visibilitySuspension.ts | 159 ++++-- packages/dashboard/app/public/sw.js | 479 ++++++++++++++-- packages/dashboard/app/sse-bus.ts | 241 +++++++-- .../__tests__/agentHealth-stale-clock.test.ts | 143 +++++ packages/dashboard/app/utils/agentHealth.tsx | 20 +- packages/dashboard/app/utils/dataFreshness.ts | 49 ++ packages/dashboard/app/utils/swrCache.ts | 79 ++- packages/dashboard/app/utils/taskStuck.ts | 12 +- 63 files changed, 7205 insertions(+), 706 deletions(-) create mode 100644 packages/dashboard/app/__tests__/component-sse-resync.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/TerminalModal.closed-mount-cost.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/TerminalModal.webgl-disposal.test.tsx create mode 100644 packages/dashboard/app/components/__tests__/terminal-scrollback-floor.test.ts create mode 100644 packages/dashboard/app/components/command-center/areas/__tests__/system-controls-job-stream.test.tsx create mode 100644 packages/dashboard/app/hooks/__tests__/resyncRetry.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useApprovalBanner.resync.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useChat.resync.test.ts create mode 100644 packages/dashboard/app/hooks/__tests__/useLiveTranscript.resync.test.ts create mode 100644 packages/dashboard/app/hooks/logStreamReconcile.ts create mode 100644 packages/dashboard/app/hooks/resyncRetry.ts create mode 100644 packages/dashboard/app/utils/__tests__/agentHealth-stale-clock.test.ts create mode 100644 packages/dashboard/app/utils/dataFreshness.ts diff --git a/.changeset/mobile-tab-discard-retention.md b/.changeset/mobile-tab-discard-retention.md index 93cee09670..8bc93381e7 100644 --- a/.changeset/mobile-tab-discard-retention.md +++ b/.changeset/mobile-tab-discard-retention.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Dashboard survives mobile tab discards and re-reads state on reconnect, so nothing is missed. +summary: Dashboard survives mobile tab discards, resyncs on reconnect, and stops caching API responses offline. category: fix -dev: Visibility-gated every polling loop via `useVisibilityAwarePoll`; one shared `useLiveTimeTicker` replaces per-TaskCard 30s timers; sse-bus suspends channels after 60s hidden and drops `beforeunload`; service worker serves hashed `/assets/*` and fonts cache-first (`fusion-cache-v6`); SWR hydration TTLs raised (tasks/chat rooms 12h, default 6h) with an oversize-aware task snapshot writer; board scroll + view persist through an involuntary reload; log/stream buffers capped at 500; ListView and search-active board columns are now windowed; the terminal modal mounts only while open and disposes its WebGL addon. Dashboard vitest setup now clears `sessionStorage` per test so per-tab view state cannot leak between cases. Follow-up hardening for the suspend window: `SseSubscription` now carries an explicit resync contract (`onReconnect`, or a reviewed `replaySafe` opt-out) with a dev-time audit and a hooks coverage ratchet, and 11 subscriber hooks refetch authoritative state on reopen; `useAgentLogs` reconciles the refetched log page with its buffer behind a visible gap marker and reports truthful `hasMore`; agent run logs are retained in full and render-windowed with "load older" instead of being discarded past 500; `useTasks` seeds `lastFetchTimeMs` from the hydrated snapshot's `savedAt` so a restored board no longer reports every in-progress card as stuck; `useVisibilityAwarePoll` staggers background subscribers deterministically on the visible edge (with a `priority: "critical"` opt-out); xterm scrollback returns to 5000/10000 lines (the server ring is smaller, not larger, than the client ring); and the service worker caps hashed-asset entries with a session-referenced exemption so the origin quota cannot be exhausted. +dev: Visibility-gated every polling loop via `useVisibilityAwarePoll`; one shared `useLiveTimeTicker` replaces per-TaskCard 30s timers; sse-bus suspends channels after 60s hidden and drops `beforeunload`; service worker serves hashed `/assets/*` and fonts cache-first (`fusion-cache-v7`); SWR hydration TTLs raised (tasks/chat rooms 12h, default 6h) with an oversize-aware task snapshot writer; board scroll + view persist through an involuntary reload; log/stream buffers capped at 500; ListView and search-active board columns are now windowed; the terminal modal mounts only while open and disposes its WebGL addon. Dashboard vitest setup now clears `sessionStorage` per test so per-tab view state cannot leak between cases. Follow-up hardening for the suspend window: `SseSubscription` now carries an explicit resync contract (`onReconnect`, or a reviewed `replaySafe` opt-out) with a dev-time audit and a hooks coverage ratchet, and 11 subscriber hooks refetch authoritative state on reopen; `useAgentLogs` reconciles the refetched log page with its buffer behind a visible gap marker and reports truthful `hasMore`; agent run logs are retained in full and render-windowed with "load older" instead of being discarded past 500; `useTasks` seeds `lastFetchTimeMs` from the hydrated snapshot's `savedAt` so a restored board no longer reports every in-progress card as stuck; `useVisibilityAwarePoll` staggers background subscribers deterministically on the visible edge (with a `priority: "critical"` opt-out); xterm scrollback returns to 5000/10000 lines (the server ring is smaller, not larger, than the client ring); and the service worker caps hashed-asset entries with a session-referenced exemption so the origin quota cannot be exhausted. Third round: the resync contract now reaches component-level subscribers too — the mailbox modal/view, the workflow node editor, and the task detail modal's workflow-results and CLI-session streams refetch on reopen (the plugin relay takes a documented `replaySafe` opt-out), with a `components/` coverage ratchet so a non-resyncing subscription cannot land silently; `useMultiAgentLogs` shares `useAgentLogs`' reconcile helper instead of replacing its buffer, and its `hasMore`/`loadMore` paging is fixed (older pages prepend, the client-only gap marker is excluded from the offset); the reconnect gap marker renders as a distinct "Missing output" warning row rather than an ordinary status update; agent heartbeat freshness measures against the data's own `dataAsOfMs` instead of `Date.now()`, so a restored tab no longer reports every agent as stale; and the service worker's `/api/` fallback cache is bounded by both an entry cap and a 5-minute freshness bound that fails closed when an entry's age is unprovable. Fourth round (review fixes): the service worker's `/api/` fallback is now an explicit ALLOW-list (`/api/tasks`, `/api/tasks/:id`, `/api/projects`) instead of a deny-list, only caches `response.ok`, stamps a durable `x-fusion-sw-cached-at` header so a cold-started worker can prove age, and the cache name moves to `fusion-cache-v7` to evacuate anything an older worker already persisted; "Clear local cache" now purges Cache Storage in-page AND via a `PURGE_CACHES` message to the controlling worker. `onReconnect` fires only when the rebuilt SSE stream actually opens (a failed attempt no longer claims to have resynced) and fans out on the same deterministic stagger as the visible-edge poll; `useProjects`/`useNodes`/`useMeshState` moved onto the shared visibility gate, which is now one `createVisibilityGatedTimer` primitive shared with `useLiveTimeTicker`. A tab-suspension or offline fetch failure no longer deletes the hydrated task snapshot, and a single SSE row no longer resets the board freshness clock before a full fetch has confirmed it. ListView "select all visible" is scoped to the rendered window, the activity log keeps the page `loadMore` just fetched, `useAgentLogs` retains paged-back history across a resync, `AgentDetailView` resyncs its task-log buffer instead of collapsing it, and the Command Center rebuild stream reconciles against REST when the server restarts mid-job. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 8ac722d2aa..ac55cb3c1e 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -360,7 +360,23 @@ function AppInner() { const params = new URLSearchParams(); if (currentProject?.id) params.set("projectId", currentProject.id); const query = params.size > 0 ? `?${params.toString()}` : ""; + /* + FNXC:PluginEvents 2026-07-26-16:46: + Resync contract (see SseSubscription in sse-bus.ts). This subscription is a pure relay: it parses + `plugin:custom`, filters by pluginId, and hands the payload to the plugin view's callback. It + holds no state of its own, so there is nothing here to refetch — and the host cannot synthesize a + refetch for the plugin either, because `plugin:custom` payloads are opaque and no generic + "current plugin state" endpoint exists. Adding a refetch here would only add a request to the + visible-edge burst without correcting anything, so this takes the documented replaySafe opt-out. + RESIDUAL RISK, stated deliberately: a plugin view that accumulates state purely from these events + still diverges across a hidden-suspend gap. The fix belongs in the plugin surface, which must + resync when its own view remounts or through its own authoritative fetch; the relay cannot do it. + */ return subscribeSse(`/api/events${query}`, { + replaySafe: { + reason: + "relay-only: no host state; plugin:custom payloads are opaque so the host has no authoritative refetch, plugin views own their resync", + }, events: { "plugin:custom": (event: MessageEvent) => { try { diff --git a/packages/dashboard/app/__tests__/component-sse-resync.test.tsx b/packages/dashboard/app/__tests__/component-sse-resync.test.tsx new file mode 100644 index 0000000000..abc2e3bf2e --- /dev/null +++ b/packages/dashboard/app/__tests__/component-sse-resync.test.tsx @@ -0,0 +1,339 @@ +/* +FNXC:DashboardSSE 2026-07-26-17:05: +Component-level coverage for the sse-bus missed-event contract (the hook-level twin lives in +`sse-resync-on-reopen.test.ts`). + +The requirement: the bus tears every EventSource down after SSE_HIDDEN_SUSPEND_DELAY_MS hidden — the +mobile page-discard fix — and /api/events keeps NO replay buffer, so every event emitted during that +window is lost forever. A COMPONENT that mutates its rendered state only from event handlers therefore +diverges permanently from the server, and the operator sees a frozen view with no indication it is +stale. + +Asserted as an invariant, not one repro: +1. Per-surface cycles for MailboxView (inbox) and TaskDetailModal (workflow-step results, CLI session) + run the full hidden -> suspend -> server moves on unobserved -> visible -> reopen sequence and + assert the surface CONVERGES on the server's state. +2. A coverage ratchet asserts every `components/` subscriber declares `onReconnect` or the reviewed + `replaySafe` opt-out, so the next non-resyncing component subscription cannot land silently. + +Fake timers (the suspend delay is 60s of wall clock) and mocked api module — no real I/O. +*/ +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { act, render, screen } from "@testing-library/react"; +import type { Column, TaskDetail, Task, MergeResult } from "@fusion/core"; +import { MockEventSource } from "../../vitest.setup"; +import { __resetSseBus, SSE_HIDDEN_SUSPEND_DELAY_MS } from "../sse-bus"; + +const fetchInboxMock = vi.fn(); +const fetchUnreadCountMock = vi.fn(); +const fetchWorkflowResultsMock = vi.fn(); +const apiMock = vi.fn(); + +vi.mock("../api", async (importOriginal) => { + const { createDashboardApiMock } = await import("../test/mockApi"); + return createDashboardApiMock(() => importOriginal>(), { + fetchInbox: fetchInboxMock, + fetchUnreadCount: fetchUnreadCountMock, + fetchWorkflowResults: fetchWorkflowResultsMock, + api: apiMock, + fetchTaskEffectiveSettings: vi.fn().mockResolvedValue({}), + fetchGlobalSettings: vi.fn().mockResolvedValue({}), + fetchAgents: vi.fn().mockResolvedValue([]), + fetchAgent: vi.fn().mockResolvedValue(null), + fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), + fetchTaskDetail: vi.fn().mockResolvedValue(null), + fetchTaskVerificationRequest: vi.fn().mockResolvedValue(null), + fetchAgentLogs: vi.fn().mockResolvedValue([]), + fetchWorkflows: vi.fn().mockResolvedValue([]), + fetchWorkflowSteps: vi.fn().mockResolvedValue([]), + fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]), + fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }), + fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, defaults: {} }), + fetchTaskReview: vi.fn().mockResolvedValue({ reviewState: undefined, automationStatus: null, emptyMessage: "" }), + }) as unknown as Promise>; +}); + +vi.mock("../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + isFullScreenSheetViewport: () => false, + isShortViewport: () => false, + getViewportMode: () => "desktop", + isMobileViewport: () => false, + useViewportMode: () => "desktop", +})); + +vi.mock("../hooks/useMobileKeyboard", () => ({ + useMobileKeyboard: () => ({ keyboardOpen: false, viewportHeight: 800 }), +})); + +vi.mock("../hooks/useAgentLogs", () => ({ + useAgentLogs: () => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false }), +})); + +vi.mock("../hooks/usePluginUiSlots", () => ({ + usePluginUiSlots: () => ({ slots: [], getSlotsForId: () => [], loading: false, error: null }), +})); + +const { MailboxView } = await import("../components/MailboxView"); +const { TaskDetailModal } = await import("../components/TaskDetailModal"); + +function setVisibility(state: "visible" | "hidden"): void { + Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); +} + +/* +FNXC:DashboardSSE 2026-07-26-17:52: +Emit as the SERVER does: into whatever socket exists, with no guarantee anyone is listening. The mock +EventSource keeps its listener array after `close()`, so a bare `_emit` on a torn-down stream would +still reach the component and silently make every case below vacuous (it did: two cases passed with +the onReconnect handlers deleted). A real closed EventSource delivers nothing, so drop the event when +the stream is CLOSED — that is precisely the loss this contract exists to survive. +*/ +function emitFromServer(type: string, data?: unknown): void { + const stream = latestStream(); + if (stream.readyState === MockEventSource.CLOSED) return; + stream._emit(type, data); +} + +function latestStream(): MockEventSource { + const instance = MockEventSource.instances[MockEventSource.instances.length - 1]; + if (!instance) throw new Error("no EventSource opened"); + return instance; +} + +async function flush(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); +} + +/** Advance while feeding the heartbeats a real stream sends, so the 45s heartbeat timeout is not what fires. */ +async function advanceWithHeartbeats(ms: number): Promise { + const STEP_MS = 20_000; + let remaining = ms; + while (remaining > STEP_MS) { + await act(async () => { + await vi.advanceTimersByTimeAsync(STEP_MS); + }); + latestStream()._emit("heartbeat"); + remaining -= STEP_MS; + } + await act(async () => { + await vi.advanceTimersByTimeAsync(remaining); + }); +} + +/** + * The shared cycle every case runs. `mutateServerWhileSuspended` stands in for the server moving on + * while the tab is backgrounded: it updates what the mocked endpoints will return AND emits the SSE + * event the server would have pushed — which must land on a torn-down socket and be lost, exactly as + * production loses it. + */ +async function suspendAndReopen(mutateServerWhileSuspended: () => void): Promise { + const preSuspendStream = latestStream(); + preSuspendStream._emit("open"); + await flush(); + + setVisibility("hidden"); + await advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS); + + mutateServerWhileSuspended(); + await flush(); + + setVisibility("visible"); + await flush(); + latestStream()._emit("open"); + await flush(); +} + +function makeMessage(id: string, subject: string) { + return { + id, + fromType: "agent", + fromId: "agent-1", + toType: "user", + toId: "user", + subject, + content: subject, + type: "info", + read: false, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +function makeTask(overrides: Partial = {}): TaskDetail { + return { + id: "FN-099", + description: "Resync task", + column: "in-progress" as Column, + dependencies: [], + prompt: "", + steps: [], + currentStep: 0, + log: [], + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + } as TaskDetail; +} + +function makeWorkflowResult(status: string) { + return { + workflowStepId: "code-review", + workflowStepName: "Code Review", + status, + phase: "pre-merge", + startedAt: "2026-01-01T00:00:00Z", + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + fetchInboxMock.mockReset(); + fetchUnreadCountMock.mockReset(); + fetchWorkflowResultsMock.mockReset(); + apiMock.mockReset(); + fetchUnreadCountMock.mockResolvedValue({ unreadCount: 0, pendingApprovalCount: 0 }); + apiMock.mockResolvedValue({ sessions: [] }); +}); + +afterEach(() => { + __resetSseBus(); + setVisibility("visible"); + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +describe("component SSE subscribers resync after a hidden-tab suspend", () => { + it("MailboxView shows a message that arrived while the tab was suspended", async () => { + fetchInboxMock.mockResolvedValue({ messages: [makeMessage("m1", "First message")], total: 1 }); + + render( + {}} + nativeStructureCandidates={[]} + />, + ); + await flush(); + expect(screen.getByText("First message")).toBeTruthy(); + + await suspendAndReopen(() => { + // Server state moves on, and the push that would have announced it hits a torn-down socket. + fetchInboxMock.mockResolvedValue({ + messages: [makeMessage("m1", "First message"), makeMessage("m2", "Arrived while hidden")], + total: 2, + }); + emitFromServer("message:received", { id: "m2" }); + }); + + expect(screen.getByText("Arrived while hidden")).toBeTruthy(); + }); + + it("TaskDetailModal workflow tab converges on a step verdict decided while suspended", async () => { + fetchWorkflowResultsMock.mockResolvedValue([makeWorkflowResult("pending")]); + + render( + {}} + onMoveTask={(async () => ({}) as Task) as never} + onDeleteTask={(async () => ({}) as Task) as never} + onMergeTask={(async () => ({ merged: false }) as MergeResult) as never} + onOpenDetail={() => {}} + addToast={() => {}} + />, + ); + await flush(); + + const badge = screen.getByTestId("workflow-result-badge-code-review"); + expect(badge.className).toContain("workflow-result-badge--pending"); + + await suspendAndReopen(() => { + fetchWorkflowResultsMock.mockResolvedValue([makeWorkflowResult("failed")]); + emitFromServer("task:updated", { id: "FN-099", workflowStepResults: [makeWorkflowResult("failed")] }); + }); + + expect(screen.getByTestId("workflow-result-badge-code-review").className).toContain( + "workflow-result-badge--failed", + ); + }); + + it("TaskDetailModal re-reads the authoritative CLI session after a suspend", async () => { + const busySession = { id: "cli-1", taskId: "FN-099", agentState: "busy", adapterId: "claude-local" }; + apiMock.mockImplementation(async (path: string) => + typeof path === "string" && path.startsWith("/cli-sessions") ? { sessions: [busySession] } : undefined, + ); + + render( + {}} + onMoveTask={(async () => ({}) as Task) as never} + onDeleteTask={(async () => ({}) as Task) as never} + onMergeTask={(async () => ({ merged: false }) as MergeResult) as never} + onOpenDetail={() => {}} + addToast={() => {}} + />, + ); + await flush(); + + const cliSessionCalls = () => + apiMock.mock.calls.filter(([path]) => typeof path === "string" && path.startsWith("/cli-sessions")).length; + const initialCalls = cliSessionCalls(); + expect(initialCalls).toBeGreaterThan(0); + + await suspendAndReopen(() => { + // The terminal transition the operator must see. It is pushed onto a dead socket, so only an + // authoritative re-read can recover it. + apiMock.mockImplementation(async (path: string) => + typeof path === "string" && path.startsWith("/cli-sessions") + ? { sessions: [{ ...busySession, agentState: "waitingOnInput" }] } + : undefined, + ); + emitFromServer("cli:session:state", { sessionId: "cli-1", taskId: "FN-099", state: "waitingOnInput" }); + }); + + expect(cliSessionCalls()).toBeGreaterThan(initialCalls); + }); +}); + +/* +FNXC:DashboardSSE 2026-07-26-17:20: +Coverage ratchet for `components/`. The hook-level ratchet in sse-resync-on-reopen.test.ts only walks +`hooks/`, which is exactly why these component subscriptions survived two rounds of the same fix. Every +component that calls subscribeSse must declare `onReconnect` or the reviewed `replaySafe` opt-out +documented on SseSubscription. +Test files are excluded: they mock the bus rather than subscribe to it. +*/ +describe("sse-bus subscriber resync contract (components)", () => { + it("every subscribing component declares onReconnect or an explicit replaySafe opt-out", () => { + const roots = [join(__dirname, "..", "components"), join(__dirname, "..")]; + const offenders: string[] = []; + + const walk = (dir: string, recurse: boolean): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (!recurse || entry.name === "__tests__" || entry.name === "node_modules") continue; + walk(join(dir, entry.name), recurse); + continue; + } + if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) continue; + if (entry.name.includes(".test.")) continue; + const source = readFileSync(join(dir, entry.name), "utf8"); + if (!source.includes("subscribeSse(")) continue; + if (source.includes("onReconnect") || source.includes("replaySafe")) continue; + offenders.push(entry.name); + } + }; + + walk(roots[0], true); + walk(roots[1], false); + + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/dashboard/app/__tests__/pwa.test.ts b/packages/dashboard/app/__tests__/pwa.test.ts index 8b2be9b8bd..46743fd554 100644 --- a/packages/dashboard/app/__tests__/pwa.test.ts +++ b/packages/dashboard/app/__tests__/pwa.test.ts @@ -2,20 +2,45 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { runInNewContext } from "node:vm"; import { inflateSync } from "node:zlib"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { loadAllAppCss } from "../test/cssFixture"; +import { + clearAllLocalCache, + PURGE_CACHES_MESSAGE, + purgeCacheStorage, +} from "../utils/swrCache"; /* FNXC:PWAOffline 2026-07-26-10:44: Restore latency after a mobile discard is a behavior, not a source-string shape, so it needs an executable seam. Evaluating sw.js in a fresh vm context with fake `caches`/`fetch` exercises the real fetch handler without a browser, a build step, or any timers — the cheapest harness that can prove "cache hit means zero network calls". */ -type FakeResponse = { ok: boolean; body: string; clone: () => FakeResponse }; +type FakeResponse = { + ok: boolean; + body: string; + clone: () => FakeResponse; + headers?: { get: (name: string) => string | null }; +}; function makeResponse(body: string, ok = true): FakeResponse { const response: FakeResponse = { ok, body, clone: () => response }; return response; } +/* +FNXC:PWAOffline 2026-07-26-15:40: +A cache entry written by a PREVIOUS service-worker session has no in-memory put timestamp, so the SW +falls back to the response's `Date` header to prove its age. This models that entry shape. +*/ +function makeDatedResponse(body: string, dateHeaderValue: string | null): FakeResponse { + const response: FakeResponse = { + ok: true, + body, + clone: () => response, + headers: { get: (name: string) => (name.toLowerCase() === "date" ? dateHeaderValue : null) }, + }; + return response; +} + type FakeRequest = { url: string; method: string; @@ -55,15 +80,53 @@ function loadServiceWorker(existingStore?: Map) { keys: async () => [...store.keys()].map((url) => ({ url })), delete: async (request: { url: string }) => store.delete(request.url), }; + /* + FNXC:PWAOffline 2026-07-26-18:05: + `keys()`/`delete()` used to be inert stubs, which made a whole-bucket purge untestable. They now model + one real bucket named after the CACHE_NAME the source declares, so `activate`'s cross-generation + cleanup still sees only the current generation (nothing to delete) while a PURGE_CACHES message can be + observed actually emptying the store. + */ + const cacheName = /const CACHE_NAME = "([^"]+)"/.exec(source)?.[1] ?? "fusion-cache"; const caches = { open: async () => cache, match: async (request: FakeRequest) => store.get(request.url), - keys: async () => [], - delete: async () => true, + keys: async () => [cacheName], + delete: async (key: string) => { + if (key !== cacheName) { + return false; + } + store.clear(); + return true; + }, }; + /* + FNXC:PWAOffline 2026-07-26-15:40: + The /api/ fallback is bounded by AGE, so the test needs to move time without waiting. The SW reads + the clock only through `Date.now()`/`Date.parse()`, so a stub Date on the vm global is the narrowest + seam that can express "this entry is six minutes old" — no fake timers, no sleeps, no real elapsed + time anywhere in the suite. + */ + const clock = { now: Date.UTC(2026, 6, 26, 12, 0, 0) }; + const DateStub = Object.assign( + function DateStub(this: unknown, ...args: unknown[]) { + return new (Date as unknown as new (...a: unknown[]) => Date)(...args); + }, + { now: () => clock.now, parse: Date.parse, UTC: Date.UTC }, + ); + const listeners = new Map void>(); const sandbox = { + Date: DateStub, + /* + FNXC:PWAOffline 2026-07-26-18:05: + Real `Response`/`Headers` so the durable put-time stamp (SW_CACHED_AT_HEADER) can be exercised end + to end. Safe for every other test: the lightweight FakeResponse carries no `status`, so + buildStampedResponse bails and the plain-clone path those tests assert on is unchanged. + */ + Response, + Headers, self: { addEventListener: (type: string, handler: (event: unknown) => void) => { listeners.set(type, handler); @@ -109,7 +172,26 @@ function loadServiceWorker(existingStore?: Map) { if (pending) await pending; } - return { handleFetch, runActivate, fetchMock, store, cache }; + async function runMessage(data: unknown): Promise { + const messageListener = listeners.get("message"); + expect(messageListener).toBeTypeOf("function"); + + let pending: Promise | undefined; + messageListener!({ + data, + waitUntil: (value: Promise) => { + pending = value; + }, + }); + + if (pending) await pending; + } + + function advanceClock(ms: number): void { + clock.now += ms; + } + + return { handleFetch, runActivate, runMessage, fetchMock, store, cache, clock, advanceClock, cacheName }; } /* @@ -131,6 +213,18 @@ function countCachedAssets(store: Map): number { return [...store.keys()].filter((url) => url.includes("/assets/")).length; } +function countCachedApiEntries(store: Map): number { + return [...store.keys()].filter((url) => url.includes("/api/")).length; +} + +/** Mirrors MAX_API_CACHE_ENTRIES / MAX_API_CACHE_AGE_MS in sw.js. */ +const MAX_API_ENTRIES = 100; +const API_CACHE_TTL_MS = 5 * 60 * 1000; + +function apiUrl(index: number): string { + return `https://fusion.test/api/tasks/FN-${String(index).padStart(4, "0")}?project=p1`; +} + const HASHED_ASSET_URL = "https://fusion.test/assets/index-CydU98D-.js"; type DecodedPng = { @@ -323,7 +417,7 @@ describe("PWA configuration", () => { expect(swSource).toContain('addEventListener("install"'); expect(swSource).toContain('addEventListener("fetch"'); expect(swSource).toContain('addEventListener("activate"'); - expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v6";'); + expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v7";'); }); it("service worker bypasses SSE requests instead of trying to cache them", () => { @@ -541,6 +635,410 @@ describe("PWA configuration", () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(response?.body).toBe(`network:${apiUrl}`); }); + + /* + FNXC:PWAOffline 2026-07-26-15:40: + The hashed-asset cap left the other unbounded writer in sw.js untouched: every GET /api/ response + was cached and nothing evicted it. The dashboard emits an open-ended set of distinct /api/ URLs + (per project, per task, per query string), so the API half grew forever — the same iOS + all-or-nothing origin-quota hazard that can take localStorage down with it. These tests pin both + halves of the bound: a count cap, and a freshness bound on the fallback so a stale response can + never be served as if it were live. + */ + it("bounds the /api/ cache across many distinct URLs instead of growing forever", async () => { + const { handleFetch, store } = loadServiceWorker(); + const requestCount = 150; + + for (let index = 0; index < requestCount; index += 1) { + await handleFetch(makeRequest(apiUrl(index))); + } + await flushPendingPrune(); + + // Unbounded growth would be 150. + expect(countCachedApiEntries(store)).toBeLessThanOrEqual(MAX_API_ENTRIES); + + // Eviction is oldest-first, so the most recent responses are the ones that survive. + expect(store.has(apiUrl(requestCount - 1))).toBe(true); + expect(store.has(apiUrl(0))).toBe(false); + }); + + it("keeps /api/ eviction from touching cached assets or the shell", async () => { + const store = new Map(); + store.set("https://fusion.test/", makeResponse("cached-shell")); + store.set(HASHED_ASSET_URL, makeResponse("cached-entry-chunk")); + const { handleFetch } = loadServiceWorker(store); + + for (let index = 0; index < 150; index += 1) { + await handleFetch(makeRequest(apiUrl(index))); + } + await flushPendingPrune(); + + expect(store.has("https://fusion.test/")).toBe(true); + expect(store.has(HASHED_ASSET_URL)).toBe(true); + }); + + it("serves a recently cached /api/ response when the network fails", async () => { + const { handleFetch, fetchMock, advanceClock } = loadServiceWorker(); + const url = apiUrl(1); + + await handleFetch(makeRequest(url)); + fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + advanceClock(30_000); + + const response = await handleFetch(makeRequest(url)); + expect(response?.body).toBe(`network:${url}`); + }); + + it("refuses to serve an expired /api/ entry and evicts it instead", async () => { + const { handleFetch, fetchMock, store, advanceClock } = loadServiceWorker(); + const url = apiUrl(2); + + await handleFetch(makeRequest(url)); + expect(store.has(url)).toBe(true); + + fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + advanceClock(API_CACHE_TTL_MS + 1_000); + + await expect(handleFetch(makeRequest(url))).rejects.toThrow("offline"); + expect(store.has(url)).toBe(false); + }); + + /* + FNXC:PWAOffline 2026-07-26-15:40: + A tab discarded by iOS restarts the service worker, so the put-time timestamps are gone while the + cache entries persist. Age must then be provable from the response's `Date` header (Fusion serves + its API from Node, which always sets it), and an entry whose age cannot be proven at all must + fail closed rather than be served. + */ + it("honours the freshness bound from the Date header after a service-worker restart", async () => { + const store = new Map(); + const freshUrl = apiUrl(3); + const staleUrl = apiUrl(4); + const { handleFetch, fetchMock, clock } = loadServiceWorker(store); + store.set(freshUrl, makeDatedResponse("previous-session-fresh", new Date(clock.now - 60_000).toUTCString())); + store.set( + staleUrl, + makeDatedResponse("previous-session-stale", new Date(clock.now - API_CACHE_TTL_MS - 60_000).toUTCString()), + ); + fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + + const fresh = await handleFetch(makeRequest(freshUrl)); + expect(fresh?.body).toBe("previous-session-fresh"); + + await expect(handleFetch(makeRequest(staleUrl))).rejects.toThrow("offline"); + expect(store.has(staleUrl)).toBe(false); + }); + + it("fails closed for an /api/ entry whose age cannot be proven", async () => { + const store = new Map(); + const url = apiUrl(5); + store.set(url, makeResponse("age-unknown")); + const { handleFetch, fetchMock } = loadServiceWorker(store); + fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + + await expect(handleFetch(makeRequest(url))).rejects.toThrow("offline"); + expect(store.has(url)).toBe(false); + }); + + it("keeps serving /api/ requests when cache pruning throws", async () => { + const { handleFetch, cache, store } = loadServiceWorker(); + cache.keys = async () => { + throw new Error("quota inspection failed"); + }; + + for (let index = 0; index < 15; index += 1) { + const response = await handleFetch(makeRequest(apiUrl(index))); + expect(response?.body).toBe(`network:${apiUrl(index)}`); + } + await flushPendingPrune(); + + expect(store.has(apiUrl(14))).toBe(true); + }); + + /* + FNXC:PWAOffline 2026-07-26-18:05: + Every successful GET /api/* response used to be written to durable Cache Storage. GET /api/settings + and /api/settings/global return plaintext `daemonToken`, `githubAuthToken`, `gitlabAuthToken`, and + `ntfyAccessToken`, so dashboard credentials were persisted on the origin and survived logout, token + rotation, and project switch. The entry cap and TTL added earlier bound size and staleness; neither + is a confidentiality control. + + These tests assert the INVARIANT ("a non-allow-listed /api/ URL is never written to and never read + from the cache") across every credential-bearing surface named in the review plus the token-in-URL + shape, not just the single reported endpoint — an allow-list regressed back to a deny-list would + still pass a one-endpoint test. + */ + const CREDENTIAL_BEARING_API_URLS = [ + "https://fusion.test/api/settings", + "https://fusion.test/api/settings/global", + "https://fusion.test/api/settings/export", + "https://fusion.test/api/settings/auth-export", + "https://fusion.test/api/secrets", + "https://fusion.test/api/secrets/list?projectId=p1", + "https://fusion.test/api/auth/providers/anthropic/login?state=xyz", + "https://fusion.test/api/agents", + "https://fusion.test/api/git/status?projectId=p1", + "https://fusion.test/api/chat/sessions", + // Allow-listed PREFIX, non-allow-listed depth: attachments carry the bearer token in the URL. + "https://fusion.test/api/tasks/FN-0001/attachments/screenshot.png?fn_token=daemon-token", + "https://fusion.test/api/artifacts/art-1/media?fn_token=daemon-token", + // Allow-listed path that nonetheless carries a token query param. + "https://fusion.test/api/tasks?projectId=p1&fn_token=daemon-token", + ]; + + it.each(CREDENTIAL_BEARING_API_URLS)("never writes %s to the cache", async (url) => { + const { handleFetch, store } = loadServiceWorker(); + + await handleFetch(makeRequest(url)); + await flushPendingPrune(); + + expect(store.has(url)).toBe(false); + expect(store.size).toBe(0); + }); + + it.each(CREDENTIAL_BEARING_API_URLS)( + "never serves %s from a cache entry an older worker left behind", + async (url) => { + const store = new Map(); + store.set(url, makeResponse('{"daemonToken":"leaked"}')); + const { handleFetch, fetchMock } = loadServiceWorker(store); + fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + + // The service worker declines to handle the request at all, so the browser performs it + // directly and the leftover entry is never read. + await expect(handleFetch(makeRequest(url))).resolves.toBeUndefined(); + }, + ); + + it("never caches a failed /api/ response as an offline fallback", async () => { + const { handleFetch, fetchMock, store } = loadServiceWorker(); + const url = "https://fusion.test/api/tasks?projectId=p1"; + // A 401 right after a token rotation, or a 500 from a restarting daemon. + fetchMock.mockImplementation(async () => makeResponse('{"error":"unauthorized"}', false)); + + const response = await handleFetch(makeRequest(url)); + + expect(response?.body).toBe('{"error":"unauthorized"}'); + expect(store.has(url)).toBe(false); + }); + + it("still caches the allow-listed board reads the mobile restore path depends on", async () => { + const { handleFetch, store } = loadServiceWorker(); + const allowed = [ + "https://fusion.test/api/tasks?projectId=p1", + "https://fusion.test/api/tasks/FN-0042", + "https://fusion.test/api/projects", + ]; + + for (const url of allowed) { + const response = await handleFetch(makeRequest(url)); + expect(response?.body).toBe(`network:${url}`); + } + + for (const url of allowed) { + expect(store.has(url)).toBe(true); + } + }); + + /* + FNXC:PWAOffline 2026-07-26-18:05: + The operator's "Clear all cached data" affordance walked localStorage only; nothing in the app + touched the caches API, so every cached response — credentials included — survived it. The purge now + routes through the service worker because the caller reloads immediately, which can abort an in-page + delete; the worker is not torn down by that reload. + */ + it("purges Cache Storage on a PURGE_CACHES message", async () => { + const { runMessage, store } = loadServiceWorker(); + store.set(HASHED_ASSET_URL, makeResponse("cached-entry-chunk")); + store.set("https://fusion.test/api/tasks", makeResponse("cached-tasks")); + store.set("https://fusion.test/", makeResponse("cached-shell")); + + await runMessage({ type: "PURGE_CACHES" }); + + expect(store.size).toBe(0); + }); + + it("ignores unrelated service-worker messages instead of purging", async () => { + const { runMessage, store } = loadServiceWorker(); + store.set(HASHED_ASSET_URL, makeResponse("cached-entry-chunk")); + + await runMessage({ type: "SKIP_WAITING" }); + + expect(store.size).toBe(1); + }); + + /* + FNXC:PWAOffline 2026-07-26-18:05: + Browsers idle-terminate a service worker after ~30s with no events — the normal state of the + BACKGROUNDED tab this whole feature targets. The next wake is a cold start whose `apiCacheTimestamps` + and `sessionReferencedAssets` are EMPTY, so a prune then has no put-time record and no pin set. It + must still bound the cache rather than throw, no-op, or evict everything. + */ + it("prunes correctly on a cold start with no in-memory timestamps or pins", async () => { + const store = new Map(); + for (let index = 0; index < 250; index += 1) { + store.set(buildAssetUrl(4, index), makeResponse("previous-session-chunk")); + } + for (let index = 0; index < 250; index += 1) { + store.set(apiUrl(index), makeResponse("previous-session-api")); + } + store.set("https://fusion.test/", makeResponse("cached-shell")); + + const { runActivate } = loadServiceWorker(store); + await runActivate(); + + expect(countCachedAssets(store)).toBe(200); + expect(countCachedApiEntries(store)).toBe(MAX_API_ENTRIES); + // Oldest-first: the newest entries of each class are the survivors. + expect(store.has(buildAssetUrl(4, 249))).toBe(true); + expect(store.has(apiUrl(249))).toBe(true); + expect(store.has(buildAssetUrl(4, 0))).toBe(false); + expect(store.has(apiUrl(0))).toBe(false); + // Unowned entries stay put. + expect(store.has("https://fusion.test/")).toBe(true); + }); + + /* + FNXC:PWAOffline 2026-07-26-18:05: + Freshness must survive that same cold start. The put-time stamp written into the stored response is + the only age proof here — the fake network response carries no `Date` header — so this proves the + durable header, not the in-memory map. + */ + it("proves /api/ entry age from the durable put-time stamp after a worker restart", async () => { + const store = new Map(); + const url = "https://fusion.test/api/tasks?projectId=p1"; + + const first = loadServiceWorker(store); + first.fetchMock.mockImplementation( + async () => new Response("live-tasks", { status: 200 }) as unknown as FakeResponse, + ); + await first.handleFetch(makeRequest(url)); + expect(store.has(url)).toBe(true); + + // Cold start: a brand-new worker instance over the same persistent cache, with empty maps. + const restarted = loadServiceWorker(store); + restarted.fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + restarted.advanceClock(60_000); + + const served = (await restarted.handleFetch(makeRequest(url))) as unknown as Response; + expect(await served.text()).toBe("live-tasks"); + + // Past the freshness bound the same cold-started worker must refuse and evict it. + const expired = loadServiceWorker(store); + expired.fetchMock.mockImplementation(async () => { + throw new Error("offline"); + }); + expired.advanceClock(API_CACHE_TTL_MS + 1_000); + + await expect(expired.handleFetch(makeRequest(url))).rejects.toThrow("offline"); + expect(store.has(url)).toBe(false); + }); + }); + + /* + FNXC:PWAOffline 2026-07-26-18:05: + Settings -> "Clear all cached data" is the operator's only purge affordance, and it reached + localStorage ONLY — nothing in the dashboard touched the caches API, so every service-worker-cached + response (credentials included, before the sw.js allow-list) survived it intact. These tests pin that + the purge now reaches Cache Storage through both paths: the service-worker message (which survives the + immediate reload the click handler performs) and the direct in-page walk (which covers a page no + worker controls). + */ + describe("operator cache purge", () => { + const originalCaches = (globalThis as { caches?: unknown }).caches; + const originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, "serviceWorker"); + + function installFakeCacheStorage(bucketNames: string[]) { + const buckets = new Set(bucketNames); + const fake = { + keys: vi.fn(async () => [...buckets]), + delete: vi.fn(async (key: string) => buckets.delete(key)), + open: vi.fn(), + match: vi.fn(), + has: vi.fn(), + }; + Object.defineProperty(globalThis, "caches", { value: fake, configurable: true, writable: true }); + return { fake, buckets }; + } + + function installController(): { postMessage: ReturnType } | null { + const controller = { postMessage: vi.fn() }; + Object.defineProperty(navigator, "serviceWorker", { + value: { controller }, + configurable: true, + }); + return controller; + } + + afterEach(() => { + Object.defineProperty(globalThis, "caches", { + value: originalCaches, + configurable: true, + writable: true, + }); + if (originalServiceWorker) { + Object.defineProperty(navigator, "serviceWorker", originalServiceWorker); + } else { + delete (navigator as unknown as Record).serviceWorker; + } + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it("purgeCacheStorage deletes every cache bucket on the origin", async () => { + const { fake, buckets } = installFakeCacheStorage(["fusion-cache-v7", "fusion-cache-v6"]); + + const deleted = await purgeCacheStorage(); + + expect(deleted).toBe(2); + expect(buckets.size).toBe(0); + expect(fake.delete).toHaveBeenCalledWith("fusion-cache-v7"); + expect(fake.delete).toHaveBeenCalledWith("fusion-cache-v6"); + }); + + it("purgeCacheStorage is a no-op when Cache Storage is unavailable", async () => { + Object.defineProperty(globalThis, "caches", { value: undefined, configurable: true, writable: true }); + + await expect(purgeCacheStorage()).resolves.toBe(0); + }); + + it("clearAllLocalCache asks the controlling worker to purge and clears localStorage", async () => { + installFakeCacheStorage(["fusion-cache-v7"]); + const controller = installController(); + localStorage.setItem("kb-dashboard-tasks-cache:p1", "{}"); + localStorage.setItem("fn.authToken", "keep-me"); + + const removed = clearAllLocalCache(); + + expect(removed).toBeGreaterThan(0); + expect(localStorage.getItem("kb-dashboard-tasks-cache:p1")).toBeNull(); + expect(localStorage.getItem("fn.authToken")).toBe("keep-me"); + expect(controller?.postMessage).toHaveBeenCalledWith({ type: PURGE_CACHES_MESSAGE }); + }); + + it("clearAllLocalCache still purges Cache Storage directly when no worker controls the page", async () => { + const { buckets } = installFakeCacheStorage(["fusion-cache-v7"]); + Object.defineProperty(navigator, "serviceWorker", { value: undefined, configurable: true }); + localStorage.setItem("kb-dashboard-projects-cache", "{}"); + + clearAllLocalCache(); + // The direct purge is fire-and-forget; drain the microtask/macrotask chain it queued. + await flushPendingPrune(); + + expect(buckets.size).toBe(0); + }); }); describe("logo assets", () => { @@ -625,7 +1123,7 @@ describe("PWA configuration", () => { expect(indexHtml).toContain(''); expect(indexHtml).toContain(''); - expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v6";'); + expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v7";'); }); }); }); diff --git a/packages/dashboard/app/__tests__/sse-bus-visibility-suspend.test.ts b/packages/dashboard/app/__tests__/sse-bus-visibility-suspend.test.ts index 0b9c0da0df..4a0a5d5b46 100644 --- a/packages/dashboard/app/__tests__/sse-bus-visibility-suspend.test.ts +++ b/packages/dashboard/app/__tests__/sse-bus-visibility-suspend.test.ts @@ -158,6 +158,179 @@ describe("sse-bus hidden-tab suspend", () => { expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true }); }); + /* + FNXC:DashboardSSE 2026-07-26-16:20: + A channel that is first subscribed (or rebuilt by a subscribe/unsubscribe cycle) DURING the hidden + window must not open a socket. The suspend used to be per-channel state armed by a single hidden + transition, so both of these cases produced a live EventSource plus a 30s keepalive for the rest of + the background period — the background work the whole suspend exists to remove, and invisible because + the tab looks idle from the UI. + */ + it("does not open an EventSource for a channel first subscribed while already hidden", () => { + setVisibility("hidden"); + vi.advanceTimersByTime(SSE_HIDDEN_SUSPEND_DELAY_MS); + const instancesBefore = MockEventSource.instances.length; + + const unsub = subscribeSse(URL_A, { events: { "task:updated": () => {} }, onReconnect: () => {} }); + + expect(MockEventSource.instances).toHaveLength(instancesBefore); + expect(__sseBusChannelState(URL_A)).toMatchObject({ + suspended: true, + hasEventSource: false, + hasKeepaliveTimer: false, + }); + + // A long hidden stretch must not resurrect it either. + vi.advanceTimersByTime(10 * SSE_HIDDEN_SUSPEND_DELAY_MS); + expect(MockEventSource.instances).toHaveLength(instancesBefore); + + // ...and it still comes back on the visible edge, so the suspend is not a silent permanent mute. + setVisibility("visible"); + expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true }); + unsub(); + }); + + it("does not let a subscribe/unsubscribe cycle rebuild a live channel while hidden", () => { + const unsubFirst = subscribeSse(URL_A, { events: { "task:updated": () => {} } }); + + setVisibility("hidden"); + advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS, MockEventSource.instances[0]!); + expect(__sseBusChannelState(URL_A)?.suspended).toBe(true); + + // Last subscriber leaves: closeChannel DELETES the channel, discarding its `suspended` flag. + unsubFirst(); + expect(__sseBusChannelState(URL_A)).toBeUndefined(); + + const instancesBefore = MockEventSource.instances.length; + const unsubSecond = subscribeSse(URL_A, { events: { "task:updated": () => {} } }); + + expect(MockEventSource.instances).toHaveLength(instancesBefore); + expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: true, hasEventSource: false }); + unsubSecond(); + }); + + it("suspends a channel that was opened inside the grace window", () => { + setVisibility("hidden"); + // Half a grace window in, a component mounts and subscribes: opening is allowed (the tab may be + // coming right back), but the channel must still be torn down at the original threshold rather + // than surviving because the hidden transition that armed the timer already passed. + vi.advanceTimersByTime(SSE_HIDDEN_SUSPEND_DELAY_MS / 2); + const unsub = subscribeSse(URL_A, { events: { "task:updated": () => {} } }); + expect(__sseBusChannelState(URL_A)).toMatchObject({ suspended: false, hasEventSource: true }); + + advanceWithHeartbeats(SSE_HIDDEN_SUSPEND_DELAY_MS, MockEventSource.instances.at(-1)!); + + expect(__sseBusChannelState(URL_A)).toMatchObject({ + suspended: true, + hasEventSource: false, + hasKeepaliveTimer: false, + }); + unsub(); + }); + + /* + FNXC:DashboardSSE 2026-07-26-16:20: + onReconnect is a refetch trigger, so firing it twice per reconnect doubles every subscriber's resync + cost (AgentDetailView refetches an unbounded run log and replaces its whole buffer). It used to fire + once synchronously in forceReconnect and again ~3s later when the replacement stream opened. + */ + it("fires onReconnect exactly once per reconnect cycle, including the forceReconnect path", () => { + const onReconnect = vi.fn(); + const unsub = subscribeSse(URL_A, { events: { "task:updated": () => {} }, onReconnect }); + + const first = MockEventSource.instances[0]!; + first._emit("open"); + expect(onReconnect).not.toHaveBeenCalled(); + + first._emit("error"); + // An attempt that has not reconnected yet must not claim to have resynced. + expect(onReconnect).not.toHaveBeenCalled(); + + // RECONNECT_DELAY_MS. + vi.advanceTimersByTime(3_000); + expect(MockEventSource.instances).toHaveLength(2); + expect(onReconnect).not.toHaveBeenCalled(); + + MockEventSource.instances[1]!._emit("open"); + expect(onReconnect).toHaveBeenCalledTimes(1); + + // No delayed second signal from the other authority. + vi.advanceTimersByTime(10_000); + expect(onReconnect).toHaveBeenCalledTimes(1); + unsub(); + }); + + /* + FNXC:DashboardSSE 2026-07-26-16:20: + ~28 subscribers declare onReconnect. Firing them in one tick issues ~28 fetches against a ~6 + connection-per-origin cap on a waking mobile radio, starving the EventSource reconnect that is + competing for the same pool and looping the failure every RECONNECT_DELAY_MS. The fan-out is + staggered on the same deterministic slot scheme as the polling side's visible-edge stagger. + */ + it("staggers the reconnect fan-out instead of firing every subscriber in one tick", () => { + const calls: number[] = []; + const unsubs = Array.from({ length: 5 }, (_, i) => + subscribeSse(URL_A, { events: { "task:updated": () => {} }, onReconnect: () => calls.push(i) }), + ); + + MockEventSource.instances[0]!._emit("open"); + MockEventSource.instances[0]!._emit("error"); + vi.advanceTimersByTime(3_000); + MockEventSource.instances[1]!._emit("open"); + + // Slot 0 stays synchronous so a single subscriber is unchanged; the rest are spread. + expect(calls).toEqual([0]); + vi.advanceTimersByTime(150); + expect(calls).toEqual([0, 1]); + vi.advanceTimersByTime(150); + expect(calls).toEqual([0, 1, 2]); + vi.advanceTimersByTime(300); + expect(calls).toEqual([0, 1, 2, 3, 4]); + + for (const unsub of unsubs) unsub(); + }); + + it("drops a pending staggered resync for a subscriber that unmounted during the window", () => { + const calls: number[] = []; + const unsubs = Array.from({ length: 5 }, (_, i) => + subscribeSse(URL_A, { events: { "task:updated": () => {} }, onReconnect: () => calls.push(i) }), + ); + + MockEventSource.instances[0]!._emit("open"); + MockEventSource.instances[0]!._emit("error"); + vi.advanceTimersByTime(3_000); + MockEventSource.instances[1]!._emit("open"); + + vi.advanceTimersByTime(150); + expect(calls).toEqual([0, 1]); + + // Deferring the fan-out means a subscriber can unmount before its slot; the delayed signal must + // not land in a torn-down consumer. + unsubs[3]!(); + vi.advanceTimersByTime(1_000); + expect(calls).toEqual([0, 1, 2, 4]); + + for (const [i, unsub] of unsubs.entries()) if (i !== 3) unsub(); + }); + + it("cancels every pending staggered resync when the channel closes mid-window", () => { + const calls: number[] = []; + const unsubs = Array.from({ length: 5 }, (_, i) => + subscribeSse(URL_A, { events: { "task:updated": () => {} }, onReconnect: () => calls.push(i) }), + ); + + MockEventSource.instances[0]!._emit("open"); + MockEventSource.instances[0]!._emit("error"); + vi.advanceTimersByTime(3_000); + MockEventSource.instances[1]!._emit("open"); + expect(calls).toEqual([0]); + + for (const unsub of unsubs) unsub(); + vi.advanceTimersByTime(5_000); + + expect(calls).toEqual([0]); + }); + it("registers no beforeunload listener on either EventSource path (bfcache eligibility)", async () => { const spy = vi.spyOn(window, "addEventListener"); try { diff --git a/packages/dashboard/app/__tests__/sse-bus.test.ts b/packages/dashboard/app/__tests__/sse-bus.test.ts index 76e5e9d299..9b2361ccc8 100644 --- a/packages/dashboard/app/__tests__/sse-bus.test.ts +++ b/packages/dashboard/app/__tests__/sse-bus.test.ts @@ -135,17 +135,40 @@ describe("sse-bus", () => { unsubB(); }); - it("fires onReconnect whenever the channel is rebuilt", () => { - const url = "/api/events"; - let reconnects = 0; - const unsub = subscribeSse(url, { - onReconnect: () => reconnects++, - }); - const es = MockEventSource.instances[0]; - // An error that tears down the connection triggers a resync signal. - es._emit("error"); - expect(reconnects).toBe(1); - unsub(); + /* + FNXC:DashboardSSE 2026-07-26-11:20: + onReconnect now fires when the REBUILT stream actually opens, not at the moment the old one errored. + A failed reconnect attempt must not claim to have resynced, and the old synchronous signal fired + BEFORE the replacement EventSource had a connection slot, so the resync burst competed with the + reconnect it was meant to follow. The test therefore has to drive the reconnect timer and emit `open` + on the replacement stream — vitest.setup's MockEventSource marks itself OPEN in its constructor but + never dispatches `open`, unlike a real EventSource. + */ + it("fires onReconnect once the rebuilt channel actually opens, not when it fails", () => { + vi.useFakeTimers(); + try { + const url = "/api/events"; + let reconnects = 0; + const unsub = subscribeSse(url, { + onReconnect: () => reconnects++, + }); + const es = MockEventSource.instances[0]; + // The channel must have genuinely connected once before a later `open` counts as a RE-connect. + es._emit("open"); + expect(reconnects).toBe(0); + // A transport error alone is not proof of a resync. + es._emit("error"); + expect(reconnects).toBe(0); + + // The scheduled reconnect builds a replacement stream; only its `open` is authoritative. + vi.advanceTimersByTime(3_000); + expect(MockEventSource.instances.length).toBeGreaterThan(1); + MockEventSource.instances[MockEventSource.instances.length - 1]._emit("open"); + expect(reconnects).toBe(1); + unsub(); + } finally { + vi.useRealTimers(); + } }); it("does not set a reconnect timer after closeChannel is called", () => { diff --git a/packages/dashboard/app/__tests__/sse-resync-on-reopen.test.ts b/packages/dashboard/app/__tests__/sse-resync-on-reopen.test.ts index f3a030043b..c0848af009 100644 --- a/packages/dashboard/app/__tests__/sse-resync-on-reopen.test.ts +++ b/packages/dashboard/app/__tests__/sse-resync-on-reopen.test.ts @@ -232,24 +232,97 @@ describe("sse-bus subscribers resync after a hidden-tab suspend", () => { FNXC:DashboardSSE 2026-07-26-15:38: Coverage ratchet. The regressions this file was written for were all the same shape — a subscriber added with event handlers and no way back to authoritative state — so per-hook cases alone would keep -letting the NEXT one through. Every hook that calls subscribeSse must declare `onReconnect` or the -explicit `replaySafe` opt-out documented on SseSubscription. +letting the NEXT one through. Every caller of subscribeSse must declare `onReconnect` or the explicit +`replaySafe` opt-out documented on SseSubscription. `useAgentLogs.ts` is knowingly absent from the exemptions: if it fails here it needs a resync, not an exemption. + +FNXC:DashboardSSE 2026-07-26-17:25: +CORRECTION to the original ratchet, which scanned `app/hooks/` ONLY and was described as covering +"every subscriber hook". That framing was wrong about what it protected: subscribeSse is not a +hooks-only API. Thirteen COMPONENT files subscribe directly (TaskDetailModal, MailboxModal, +GroupTaskModal, MailboxView, PluginManager, WorkflowNodeEditor, PlanningModeModal, BranchGroupCard, +SettingsModal, MissionManager, command-center/MissionControlPanel, command-center/areas/ +SystemControlsArea, AgentDetailView). All comply today, but a NEW non-compliant component subscriber — +precisely the regression this ratchet exists to stop, and precisely how two subscribers slipped through +in the change set that motivated it — was invisible to the scan. The walk is now recursive over both +`app/hooks/` and `app/components/` (nested `command-center/` and `command-center/areas/` included). + +The walker itself is guarded: a scan that silently stops finding files would pass with zero offenders, +which is the same silent-incorrectness failure mode the SSE work keeps producing. `scanSubscribers` +therefore returns what it examined and the test below asserts the known nested subscribers were +actually reached. Shape follows the repo's existing static call-site gate, +`packages/engine/src/__tests__/engine-no-blocking-shellout.test.ts`. */ -describe("sse-bus subscriber resync contract (hooks)", () => { - it("every subscriber hook declares onReconnect or an explicit replaySafe opt-out", () => { - const hooksDir = join(__dirname, "..", "hooks"); - const offenders: string[] = []; +const SUBSCRIBER_ROOTS = ["hooks", "components"] as const; - for (const file of readdirSync(hooksDir)) { - if (!file.endsWith(".ts") && !file.endsWith(".tsx")) continue; - const source = readFileSync(join(hooksDir, file), "utf8"); +/** + * Nested subscriber files the walk must reach. Not an allowlist — these already comply. They exist so + * a broken/shallow walker fails loudly instead of reporting an empty offenders array. Paths are + * relative to `app/`, POSIX-separated. + */ +const REQUIRED_SCAN_WITNESSES = [ + "hooks/useTasks.ts", + "components/TaskDetailModal.tsx", + "components/command-center/MissionControlPanel.tsx", + "components/command-center/areas/SystemControlsArea.tsx", +] as const; + +type ScanResult = { offenders: string[]; subscribers: string[] }; + +function scanSubscribers(): ScanResult { + const appDir = join(__dirname, ".."); + const offenders: string[] = []; + const subscribers: string[] = []; + + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + // Test fixtures may legitimately subscribe without resyncing. + if (entry.name === "__tests__" || entry.name === "node_modules") continue; + walk(join(dir, entry.name), relativePath); + continue; + } + if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) continue; + if (entry.name.includes(".test.")) continue; + const source = readFileSync(join(dir, entry.name), "utf8"); if (!source.includes("subscribeSse(")) continue; + subscribers.push(relativePath); if (source.includes("onReconnect") || source.includes("replaySafe")) continue; - offenders.push(file); + offenders.push(relativePath); } + }; - expect(offenders).toEqual([]); + for (const root of SUBSCRIBER_ROOTS) walk(join(appDir, root), root); + return { offenders, subscribers }; +} + +describe("sse-bus subscriber resync contract (hooks and components)", () => { + it("every subscribeSse call site declares onReconnect or an explicit replaySafe opt-out", () => { + const { offenders } = scanSubscribers(); + expect( + offenders, + [ + "SSE RESYNC REGRESSION: the listed files call subscribeSse() with no `onReconnect` and no", + "`replaySafe` opt-out. The bus tears every EventSource down after SSE_HIDDEN_SUSPEND_DELAY_MS", + "hidden and /api/events has NO replay buffer, so every event emitted while suspended is lost", + "forever. Without a resync path the surface renders state that looks complete and is not.", + "Add `onReconnect: () => void refetch()`, or `replaySafe: true` if — and only if — the", + "subscriber derives nothing durable from events.", + ].join(" "), + ).toEqual([]); + }); + + it("the scan actually reaches nested component subscribers (walker self-check)", () => { + const { subscribers } = scanSubscribers(); + for (const witness of REQUIRED_SCAN_WITNESSES) { + expect( + subscribers, + `sse-resync ratchet: the walker did not reach ${witness}. Either the file moved (re-point this witness) or the walk broke — a broken walk reports zero offenders and the ratchet silently stops guarding anything.`, + ).toContain(witness); + } + // Guard against a filter that quietly drops most of the tree. + expect(subscribers.length).toBeGreaterThanOrEqual(REQUIRED_SCAN_WITNESSES.length); }); }); diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index b8e0cde8a5..684805cf69 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -25,6 +25,7 @@ import type { AgentHealthStatus } from "../utils/agentHealth"; import { SkillMultiselect } from "./SkillMultiselect"; import { subscribeSse } from "../sse-bus"; import { MAX_LOG_ENTRIES } from "../hooks/useAgentLogs"; +import { countLeadingGapMarkers, reconcileReconnectedEntries } from "../hooks/logStreamReconcile"; import { DEFAULT_HEARTBEAT_INTERVAL_MS, formatHeartbeatInterval, resolveHeartbeatIntervalMs } from "../utils/heartbeatIntervals"; import { formatAgentSkillBadgeLabel } from "../utils/agentSkills"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -164,6 +165,15 @@ default) and grows backwards, the mirror image of the board's top-anchored windo const LOG_WINDOW_INITIAL = MAX_LOG_ENTRIES; const LOG_WINDOW_INCREMENT = MAX_LOG_ENTRIES; +/* +FNXC:AgentLogResync 2026-07-26-18:02: +Page size for the task-log fetch (matches `useAgentLogs`'s INITIAL_LOAD_LIMIT) and the hard ceiling on a +reconnect refetch. The ceiling exists only to bound one request; it is not a retention cap, and it must +never be applied to an array already held in state — see the correction on LOG_WINDOW_INITIAL. +*/ +const AGENT_LOG_PAGE_LIMIT = 100; +const AGENT_LOG_RESYNC_MAX_LIMIT = 1000; + /** * FNXC:AgentLogHistory 2026-07-26-13:08: * Live SSE append with a SOFT ceiling, identical in intent to `useAgentLogs`'s tail: the buffer is @@ -275,6 +285,16 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild const agentRef = useRef(null); const hasConfigChangesRef = useRef(false); const loadedLatestRunLogsRef = useRef(null); + /* + FNXC:AgentLogResync 2026-07-26-18:10: + `logs` mirrored into a ref plus an identifier for the stream that filled it. `loadLogs` needs the + current buffer length (to size the resync page) and its provenance (to decide merge-vs-replace), but + reading either from state would put them in `loadLogs`'s dependency list, and `loadLogs` is a + dependency of the Logs-tab effect — every streamed line would then refetch the log page. + */ + const logsRef = useRef([]); + logsRef.current = logs; + const logsSourceRef = useRef(null); // Track the context version to detect stale events after project/agent switches. // Incremented whenever agentId or projectId changes, invalidating any in-flight SSE handlers. @@ -337,11 +357,44 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild try { if (agent?.taskId) { + const currentTaskId = agent.taskId; setLatestRun(null); loadedLatestRunLogsRef.current = null; - const result = await fetchAgentLogsWithMeta(agent.taskId, currentProjectId, { limit: 100 }); + /* + FNXC:AgentLogResync 2026-07-26-18:05: + Task-log refetch. This path is BOTH the initial load and the SSE-reconnect heal, and it used to + `setLogs(result.entries)` — a wholesale replace with a 100-entry page. After a hidden-tab suspend + an agent that had streamed 480 lines into the buffer was silently cut to the newest 100: this view + has no server-paging path (WindowedAgentLogViewer pages only over the array already in memory), so + `hiddenCount` became 0, the "Load older" affordance disappeared, and a truncated log was presented + as complete. Both halves of the fix are required: + 1. request AT LEAST as many entries as the buffer already holds, so the fetched page provably + overlaps the buffer and the splice loses nothing; + 2. merge through the SHARED `reconcileReconnectedEntries` rather than replacing, so an + unprovable splice renders a visible gap marker instead of implied continuity. + The request is clamped at AGENT_LOG_RESYNC_MAX_LIMIT so a very large buffer cannot turn one + reconnect into an unbounded query; past that ceiling the reconcile's gap marker is the honest + outcome. + + The limit is `max(PAGE, heldRealCount)` and deliberately NOT `heldRealCount + PAGE`: the reconcile + splices when the fetched page STARTS INSIDE the buffer, so a page reaching further back than the + buffer's first entry has no overlap and would stamp a gap marker on a buffer that in fact lost + nothing — a false "entries are missing" claim. + + `logsSourceRef` gates the merge on the buffer belonging to this same task stream. The Logs tab also + fills `logs` from the latest-RUN fallback, and reconciling a task page against a run buffer would + likewise fabricate a gap marker between two unrelated streams; a source change is a plain replace. + */ + const sameSource = logsSourceRef.current === `task:${currentTaskId}`; + const heldEntries = sameSource ? logsRef.current : []; + const heldRealCount = heldEntries.length - countLeadingGapMarkers(heldEntries); + const limit = Math.min(AGENT_LOG_RESYNC_MAX_LIMIT, Math.max(AGENT_LOG_PAGE_LIMIT, heldRealCount)); + const result = await fetchAgentLogsWithMeta(currentTaskId, currentProjectId, { limit }); if (isStale()) return; - setLogs(result.entries); + setLogs((prev) => + reconcileReconnectedEntries(sameSource ? prev : [], result.entries, [], currentTaskId).entries, + ); + logsSourceRef.current = `task:${currentTaskId}`; return; } @@ -353,6 +406,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild setLatestRun(latest); if (!latest) { loadedLatestRunLogsRef.current = null; + logsSourceRef.current = null; setLogs([]); return; } @@ -365,6 +419,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild // by WindowedAgentLogViewer instead. Capping here destroyed the run's opening entries outright // (see the correction note on LOG_WINDOW_INITIAL). setLogs(entries); + logsSourceRef.current = `run:${latest.id}`; loadedLatestRunLogsRef.current = latest.id; } catch (err) { if (isStale()) return; @@ -571,6 +626,7 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild setAgentMailbox(null); setMailboxError(null); loadedLatestRunLogsRef.current = null; + logsSourceRef.current = null; hasConfigChangesRef.current = false; } }, [agentId, projectId]); @@ -612,6 +668,26 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild "approval:updated": refreshAgentForApprovalEvent, "approval:decided": refreshAgentForApprovalEvent, }, + /* + FNXC:AgentDetailResync 2026-07-26-18:18: + This subscription drives the WHOLE detail header (state badge, health, error indicator) and the + approval indicator purely from events — nothing else refetches on demand. `/api/events` replays + nothing on open and the bus tears the socket down after SSE_HIDDEN_SUSPEND_DELAY_MS hidden, so + every `agent:updated`/`approval:*` emitted during the suspend window is gone. Without this + handler an agent that went to `error` and raised an approval kept rendering as its pre-suspend + self on return. Not forever — the 30s `loadAgent` poll above eventually corrects it (do not + re-file this as a permanent stale view) — but that timer does not run through a frozen/suspended + tab, so the operator can stare at a confidently wrong header for up to a further 30s after + resume. Refetch authoritative agent state at the reopen instead. + + The three log subscriptions in this file each got `onReconnect` when the suspend landed and this + one was missed; every subscribeSse here now declares one. + */ + onReconnect: () => { + if (contextVersionRef.current !== contextVersionAtStart) return; + if (hasConfigChangesRef.current) return; + void loadAgent(); + }, }); }, [agentId, projectId, loadAgent]); diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index 50da4e68f2..314c3a7971 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -346,8 +346,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree `tasks` handed to this column are ALREADY search-filtered upstream, so paginating them still shows matches — just an increment at a time behind the same "Load more" button. The remaining bypasses are bounded: the archived column is server-paginated (100 per page) and worktree grouping renders only - the WIP/processing lane. The window resets whenever the search term toggles so a new result set - starts from one screenful again. + the WIP/processing lane. */ const shouldPaginate = !isArchived && !showWorktreeGroups && tasks.length > PAGINATED_COLUMN_THRESHOLD; @@ -361,10 +360,32 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree }); }, [showWorktreeGroups, isArchived, tasks.length]); - // Entering/leaving search replaces the result set; collapse back to one window. + /* + FNXC:BoardColumnWindowing 2026-07-26-14:20: + Correction of a false claim: the block above previously stated "the window resets whenever the search + term toggles", but the reset effect keyed on `isSearchActive`, a BOOLEAN. Editing a query from one + broad term to another keeps that boolean true, so a window expanded to hundreds of cards by repeated + "Load more" survived into an entirely new result set — reinstating the unbounded DOM this change + removed, via an ordinary search refinement. + + Column is not given the query text (Board/Lane pass only `isSearchActive`), and the query string is + not the real invariant anyway: what must stay bounded is the RESULT SET. So the reset keys on a cheap + identity signature of the incoming filtered `tasks` while search is active — length plus the first + and last id. Refining a query changes at least one of those, collapsing the window back to one + screenful; a re-render or poll that yields the same result set produces the same string and does NOT + disturb the operator's expanded window (an effect keyed on the array itself would fire every poll). + A query edit that yields a byte-identical result set intentionally keeps its window: the DOM size is + unchanged, so there is nothing to bound. + */ + const searchResultSignature = useMemo(() => { + if (!isSearchActive || tasks.length === 0) return ""; + return `${tasks.length}:${tasks[0]?.id ?? ""}:${tasks[tasks.length - 1]?.id ?? ""}`; + }, [isSearchActive, tasks]); + + // Entering/leaving search, or landing on a different search result set, collapses back to one window. useEffect(() => { setVisibleTaskCount(VISIBLE_TASKS_INITIAL); - }, [isSearchActive]); + }, [isSearchActive, searchResultSignature]); const handleDragOver = useCallback((e: React.DragEvent) => { // Don't allow dropping into archived column via drag-drop diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index 7eedc332f4..abe585a070 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -103,6 +103,9 @@ Invariants this window must not break: projectStorage), so a selected task outside the window stays selected. The window is additionally widened to cover the persisted single selection so the highlighted row remains visible after a remount rather than silently vanishing from the rendered list. +- Bulk select-all is scoped to the RENDERED window, not the filtered set. See the + FNXC:ListViewSelectAll block on `selectAllTaskIds`; this invariant was missing from the original + windowing change and the "Select all visible tasks" label was false until it was added. */ const LIST_SECTION_VISIBLE_INITIAL = 50; const LIST_SECTION_VISIBLE_INCREMENT = 25; @@ -1009,46 +1012,75 @@ export function ListView({ })); }, []); - // Selection logic that depends on groupedTasks (must be after groupedTasks definition) - // Toggle all visible tasks - const toggleSelectAll = useCallback(() => { - const visibleTaskIds = Object.values(groupedTasks) - .flat() - .filter((t) => !isArchivedColumn(t.column)) // Can't bulk edit archived - .map((t) => t.id); + /* + FNXC:ListViewSelectAll 2026-07-26-14:05: + The header checkbox is labelled "Select all visible tasks" and the bulk bar behind it performs + DESTRUCTIVE actions (bulk delete, bulk column move). Before render windowing it flattened + `groupedTasks` and that was honest, because every filtered row was in the DOM. Windowing broke the + label: on a 3000-task project the operator sees 50 rows and the old handler armed 3000 for deletion. + Correction of a false claim: the earlier windowing FNXC block enumerated filtering, grouping and + single-selection invariants and asserted nothing about bulk selection — it did NOT hold. A bulk + action must never reach a row the operator cannot see, so select-all is scoped to what is actually + rendered. - setSelectedTaskIds((prev) => { - const allSelected = visibleTaskIds.every((id) => prev.has(id)); - if (allSelected) { - // Deselect all visible - const next = new Set(prev); - visibleTaskIds.forEach((id) => next.delete(id)); - return next; - } else { - // Select all visible - return new Set([...prev, ...visibleTaskIds]); + "Rendered" here mirrors the two render loops (single-pane cards and the table) exactly: the + selected-column filter, the hide-done/archived section skip, the collapsed-section skip (a collapsed + section renders no rows), and the per-section window slice. Archived rows are then dropped because + bulk edit cannot act on them. Keep this in sync with both loops — if a loop grows another skip, it + belongs here too, or the label lies again. + */ + const selectAllTaskIds = useMemo(() => { + const ids: string[] = []; + for (const columnDef of listColumns) { + const column = columnDef.id; + if (selectedColumn && column !== selectedColumn) continue; + if (hideDoneTasks && (columnDef.flags.complete || columnDef.flags.archived) && !selectedColumn) continue; + if (collapsedSections.has(column)) continue; + const group = groupedTasks[column]; + if (!group || group.length === 0) continue; + const windowed = listSectionWindows[column]?.tasks ?? group; + for (const task of windowed) { + if (isArchivedColumn(task.column)) continue; // Can't bulk edit archived + ids.push(task.id); } + } + return ids; + }, [ + listColumns, + selectedColumn, + hideDoneTasks, + collapsedSections, + groupedTasks, + listSectionWindows, + isArchivedColumn, + ]); + + // Toggle every rendered (windowed) task + const toggleSelectAll = useCallback(() => { + setSelectedTaskIds((prev) => { + const allSelected = selectAllTaskIds.every((id) => prev.has(id)); + if (allSelected) { + // Deselect the rendered rows, leaving any selection made outside the current window intact. + const next = new Set(prev); + selectAllTaskIds.forEach((id) => next.delete(id)); + return next; + } + return new Set([...prev, ...selectAllTaskIds]); }); - }, [groupedTasks, isArchivedColumn]); + }, [selectAllTaskIds]); - // Check if all visible tasks are selected + // Check if all rendered tasks are selected const isSelectAll = useMemo(() => { - const visibleTaskIds = Object.values(groupedTasks) - .flat() - .filter((t) => !isArchivedColumn(t.column)); - if (visibleTaskIds.length === 0) return false; - return visibleTaskIds.every((t) => selectedTaskIds.has(t.id)); - }, [groupedTasks, isArchivedColumn, selectedTaskIds]); + if (selectAllTaskIds.length === 0) return false; + return selectAllTaskIds.every((id) => selectedTaskIds.has(id)); + }, [selectAllTaskIds, selectedTaskIds]); - // Check if some (but not all) visible tasks are selected + // Check if some (but not all) rendered tasks are selected const isSelectIndeterminate = useMemo(() => { - const visibleTaskIds = Object.values(groupedTasks) - .flat() - .filter((t) => !isArchivedColumn(t.column)); - if (visibleTaskIds.length === 0) return false; - const selectedCount = visibleTaskIds.filter((t) => selectedTaskIds.has(t.id)).length; - return selectedCount > 0 && selectedCount < visibleTaskIds.length; - }, [groupedTasks, isArchivedColumn, selectedTaskIds]); + if (selectAllTaskIds.length === 0) return false; + const selectedCount = selectAllTaskIds.filter((id) => selectedTaskIds.has(id)).length; + return selectedCount > 0 && selectedCount < selectAllTaskIds.length; + }, [selectAllTaskIds, selectedTaskIds]); // Bulk edit state and handlers (must be after groupedTasks and clearSelection definition) const [availableNodes, setAvailableNodes] = useState([]); diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index b3a0edbb4e..336b6cf385 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -398,7 +398,17 @@ export function MailboxModal({ } }; + /* + FNXC:MailboxModal 2026-07-26-16:20: + Resync contract (see SseSubscription in sse-bus.ts). The modal's message lists and unread count are + mutated ONLY by these events, and the stream is lossy: an error/heartbeat reconnect or the >=60s + hidden-tab suspend drops the socket and /api/events keeps no replay buffer. Without onReconnect a + message sent while the phone was backgrounded never appears and the unread badge under-counts until + the operator manually switches tabs. `onMailboxUpdate` is the same authoritative reload the events + already trigger, so reusing it needs no new endpoint. + */ return subscribeSse(`/api/events${query}`, { + onReconnect: onMailboxUpdate, events: { "message:sent": onMailboxUpdate, "message:received": onMailboxUpdate, diff --git a/packages/dashboard/app/components/MailboxView.tsx b/packages/dashboard/app/components/MailboxView.tsx index 058daf8662..12ec777c3f 100644 --- a/packages/dashboard/app/components/MailboxView.tsx +++ b/packages/dashboard/app/components/MailboxView.tsx @@ -588,7 +588,17 @@ export function MailboxView({ } }; + /* + FNXC:MailboxView 2026-07-26-16:22: + Resync contract (see SseSubscription in sse-bus.ts). Inbox/outbox/approvals lists and the unread + count are derived ONLY from these events, and the stream is lossy: an error/heartbeat reconnect or + the >=60s hidden-tab suspend drops the socket and /api/events keeps no replay buffer. The costly + case is `approval:requested` — an approval raised while the tab was backgrounded stayed invisible + and the agent blocked on a decision nobody was shown. `onMailboxUpdate` is the same authoritative + reload the events already trigger, so reusing it needs no new endpoint. + */ return subscribeSse(`/api/events${query}`, { + onReconnect: onMailboxUpdate, events: { "message:sent": onMailboxUpdate, "message:received": onMailboxUpdate, diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index f84c0ac5af..0ce700cc5c 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -231,6 +231,21 @@ the existing compact padding rule keeps the affordance usable below the mobile b background: color-mix(in srgb, var(--color-warning) 10%, var(--surface)); } +/* +FNXC:TaskChat-LogGap 2026-07-26-17:30: +The reconnect gap marker must not read as ordinary chat or as a routine status update: it reports +that output is MISSING. Reuse the `.task-chat-entry` block and error token, and mark the break with a +dashed edge so the row scans as a discontinuity in the transcript rather than content in it. +Tokens only — no forked entry variant, no hardcoded px/hex. +*/ +.task-chat-entry--gap { + border-style: dashed; + border-color: color-mix(in srgb, var(--color-error) 55%, var(--border)); + background: color-mix(in srgb, var(--color-error) 8%, var(--surface)); + color: var(--text-muted); +} + +.task-chat-entry--gap .task-chat-entry-label-row, .task-chat-entry--status .task-chat-entry-label-row { justify-content: flex-start; gap: var(--space-xs); @@ -486,6 +501,11 @@ FN-7241 adds timestamps inside individual task-detail transcript blocks. Keep bl color: var(--color-error); } +/* FNXC:TaskChat-LogGap 2026-07-26-17:33: gap notice reuses the tool-error kicker tone so "output is missing" reads as a warning, not a status update. Declared after the base kicker rule so it overrides it. */ +.task-chat-entry--gap .task-chat-entry-kicker { + color: var(--color-error); +} + .task-chat-entry-text { white-space: pre-wrap; } diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 2dcd0848d5..6ea363e1fd 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -2,11 +2,12 @@ import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; +import { AlertTriangle, ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { addSteeringComment, refineTask } from "../api"; import { useAgentLogs } from "../hooks/useAgentLogs"; +import { isLogGapMarker } from "../hooks/logStreamReconcile"; import { useComposerDictation } from "../hooks/useComposerDictation"; import { MicButton } from "./MicButton"; import type { ToastType } from "../hooks/useToast"; @@ -399,9 +400,39 @@ function segmentGroupEntries(entries: AgentLogEntry[]): TaskChatSegment[] { return segments; } +/* +FNXC:TaskChat-LogGap 2026-07-26-17:25: +The reconnect gap marker says OUTPUT IS MISSING from the transcript. It is carried as a `status` +entry, so it used to render as an ordinary "Status update" row — the same chrome the engine uses for +routine progress messages — and read as chat content rather than a warning. That is the failure this +whole marker exists to prevent: a reader who does not notice the row believes the transcript is +complete. +Rendered as its own affordance: the shared `status-dot--error` convention plus a lucide AlertTriangle +and a dashed-edge variant of the existing `.task-chat-entry` block (tokens only, no forked +component), and `role="status"` so assistive tech announces it as a state message, not prose. +*/ +function TaskChatLogGapNotice({ entry }: { entry: AgentLogEntry }) { + return ( +
+
+
+
{entry.text}
+
+ ); +} + function TaskChatText({ entries }: { entries: AgentLogEntry[] }) { const firstEntry = entries[0]; if (!firstEntry) return null; + if (isLogGapMarker(firstEntry)) return ; return (
{ try { @@ -1403,22 +1404,58 @@ export function TaskDetailContent({ } }; - return subscribeSse(`/api/events${query}`, { + /* + FNXC:TaskWorkflowDetails 2026-07-26-16:30: + Resync contract (see SseSubscription in sse-bus.ts). After the initial fetch the Workflow tab's step + results are replaced ONLY by `task:updated` payloads, and the stream is lossy: an error/heartbeat + reconnect or the >=60s hidden-tab suspend drops the socket and /api/events keeps no replay buffer. + Missing the gap freezes the rendered step list at its pre-suspend state — a review that failed or a + step that finished while the phone was backgrounded still reads as running, which is exactly the + surface an operator checks before deciding to intervene. Refetch through the same + `fetchWorkflowResults` the load effect uses; deliberately no `setWorkflowResultsLoading(true)` and no + list clear, so a reconnect refreshes in place instead of flashing an empty/spinner tab. + */ + const resyncWorkflowResults = () => { + void fetchWorkflowResults(task.id, projectId) + .then((results) => { + if (!cancelled) setWorkflowResults(results); + }) + .catch(() => { + // Non-fatal: the tab keeps its last known rows and the next task:updated event corrects them. + }); + }; + + const unsubscribe = subscribeSse(`/api/events${query}`, { + onReconnect: resyncWorkflowResults, events: { "task:updated": handleTaskUpdated }, }); + return () => { + cancelled = true; + unsubscribe(); + }; }, [activeTab, task.id, projectId]); + /* + FNXC:TaskCliSession 2026-07-26-16:36: + Hoisted out of the load effect so the `cli:session:state` subscription's onReconnect can refetch the + SAME authoritative list rather than duplicating the request shape. Returns the most-recent session + (the list is store-ordered) or null; the enriched list fields (adapterId / autonomyPosture) exist only + here, which is why the SSE handler merges onto this record instead of replacing it. + */ + const fetchLatestCliSession = useCallback(async (): Promise => { + const search = new URLSearchParams({ taskId: task.id }); + if (projectId) search.set("projectId", projectId); + const res = await api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`); + const sessions = res.sessions ?? []; + return sessions.length > 0 ? sessions[sessions.length - 1] : null; + }, [task.id, projectId]); + // Load the CLI agent session for this task (drives the terminal tab + matrix). useEffect(() => { let cancelled = false; - const search = new URLSearchParams({ taskId: task.id }); - if (projectId) search.set("projectId", projectId); - void api<{ sessions: CliSessionSummaryRecord[] }>(`/cli-sessions?${search.toString()}`) - .then((res) => { - if (cancelled) return; - // Most-recent session for the task (the list is store-ordered). - const sessions = res.sessions ?? []; - setCliSession(sessions.length > 0 ? sessions[sessions.length - 1] : null); + void fetchLatestCliSession() + .then((session) => { + if (!cancelled) setCliSession(session); }) .catch(() => { if (!cancelled) setCliSession(null); @@ -1426,13 +1463,14 @@ export function TaskDetailContent({ return () => { cancelled = true; }; - }, [task.id, projectId]); + }, [fetchLatestCliSession]); // Live CLI session state via SSE — MERGE payload fields onto the record // (never wholesale-replace: the list fetch carries enriched fields the SSE // payload omits, e.g. adapterId / autonomyPosture). useEffect(() => { const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + let cancelled = false; const handleCliState = (e: MessageEvent) => { try { const payload = JSON.parse(e.data) as { @@ -1473,10 +1511,35 @@ export function TaskDetailContent({ /* skip malformed events */ } }; - return subscribeSse(`/api/events${query}`, { + /* + FNXC:TaskCliSession 2026-07-26-16:40: + Resync contract (see SseSubscription in sse-bus.ts). `agentState` is advanced ONLY by + `cli:session:state` after the initial list fetch, and the stream is lossy: an error/heartbeat + reconnect or the >=60s hidden-tab suspend drops the socket with no replay buffer. A terminal + transition landing in that gap is the expensive one — the session shows "busy" forever while the + real process is `waitingOnInput` (operator never answers the prompt) or `dead`/`done` (operator + waits on a session that already ended). Refetch the list on reconnect and take it as authoritative: + unlike the event handler, the list response carries every enriched field, so replacing is safe here. + */ + const resyncCliSession = () => { + void fetchLatestCliSession() + .then((session) => { + if (!cancelled) setCliSession(session); + }) + .catch(() => { + // Non-fatal: keep the last known record; the next state event or reopen corrects it. + }); + }; + + const unsubscribe = subscribeSse(`/api/events${query}`, { + onReconnect: resyncCliSession, events: { "cli:session:state": handleCliState }, }); - }, [task.id, projectId]); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [task.id, projectId, fetchLatestCliSession]); // Reset dependency search when dropdown closes useEffect(() => { diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index b1ac74275e..601cdcfbe4 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -1244,7 +1244,16 @@ function InnerEditor({ const refreshFromWorkflowMutation = () => { void loadWorkflows({ forceFresh: true }); }; + /* + FNXC:ChatWorkflowAuthoring 2026-07-26-16:24: + Resync contract (see SseSubscription in sse-bus.ts). The editor's workflow list is refreshed ONLY by + these events after the initial load, and the stream is lossy: an error/heartbeat reconnect or the + >=60s hidden-tab suspend drops the socket with no replay buffer. A workflow created or deleted while + the editor sat backgrounded would otherwise stay missing (or stay listed after deletion, so opening + it 404s) until a hard reload. The reconnect reuses the same force-fresh reload the events use. + */ return subscribeSse(`/api/events${query}`, { + onReconnect: refreshFromWorkflowMutation, events: { "workflow:created": refreshFromWorkflowMutation, "workflow:updated": refreshFromWorkflowMutation, diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 53dcd24e10..a052441ab3 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -363,16 +363,49 @@ vi.mock("../../components/ScriptsModal", () => ({ ) : null, })); -vi.mock("../../components/TerminalModal", () => ({ - TerminalModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => - isOpen ? ( -
- -
- ) : null, -})); +/* +FNXC:Terminal 2026-07-26-19:30: +This stand-in used to be `isOpen ?
: null` and nothing else. That made App's terminal MOUNT +decision structurally unverifiable: the stand-in rendered identically whether App always mounted it with +`isOpen={false}` or did not mount it at all, so a regression back to always-mounted — which costs a live +PTY WebSocket and a 45s heartbeat on a backgrounded tab, the tab-discard signal the conditional mount at +App.tsx ~1927 exists to remove — passed the whole suite. +`terminalLifecycle` records the two things the DOM cannot show: whether the component function was +invoked at all, and whether it was unmounted. Existing DOM-level tests are unaffected; the markup is +unchanged. +*/ +const terminalLifecycle = { + renders: [] as boolean[], + mounts: 0, + unmounts: 0, + reset(): void { + this.renders = []; + this.mounts = 0; + this.unmounts = 0; + }, +}; + +vi.mock("../../components/TerminalModal", async () => { + const { useEffect } = await import("react"); + return { + TerminalModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) => { + terminalLifecycle.renders.push(isOpen); + useEffect(() => { + terminalLifecycle.mounts += 1; + return () => { + terminalLifecycle.unmounts += 1; + }; + }, []); + return isOpen ? ( +
+ +
+ ) : null; + }, + }; +}); vi.mock("../../components/AgentsView", () => ({ AgentsView: () =>
Agents view
, @@ -4672,3 +4705,78 @@ describe("App shell connection status plumbing", () => { expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull(); }); }); + +/* +FNXC:Terminal 2026-07-26-19:35: +App must render TerminalModal ONLY while `modalManager.terminalOpen` (App.tsx ~1927). A closed-but-mounted +terminal still runs `useTerminalSessions` + `useTerminal`: a live PTY WebSocket and a 45s heartbeat +interval, both of which keep a backgrounded tab awake and get it discarded on iOS Safari / Chrome Android. +That cost is proved against the real component in `TerminalModal.closed-mount-cost.test.tsx`; this block +proves App does not pay it. + +Asserted on the component-function invocation and unmount, not on rendered DOM. The DOM cannot express +the difference — a mounted TerminalModal with `isOpen={false}` renders nothing, exactly like an unmounted +one — which is why the previous shallow stand-in left this invariant unverifiable and a regression to +always-mounted silently green. +*/ +describe("terminal mount lifecycle (App mounts the terminal only while open)", () => { + it("never mounts TerminalModal while the terminal is closed, and unmounts it on close", async () => { + terminalLifecycle.reset(); + render(); + + await waitFor(() => { + expect(screen.getByTitle("Settings")).toBeTruthy(); + }); + + expect( + terminalLifecycle.renders, + "TERMINAL MOUNT REGRESSION: App rendered TerminalModal while the terminal was closed. A mounted TerminalModal opens a PTY WebSocket and arms a 45s heartbeat even with isOpen={false} (see TerminalModal.closed-mount-cost.test.tsx), which is the background work that gets a backgrounded mobile tab discarded. Keep the `modalManager.terminalOpen &&` guard on the render in App.tsx.", + ).toEqual([]); + expect(terminalLifecycle.mounts).toBe(0); + + // Open the terminal through the Scripts -> Run path. + await act(async () => { + fireEvent.click(await screen.findByTestId("scripts-btn")); + }); + await waitFor(() => { + expect(screen.getByTestId("quick-scripts-manage")).toBeTruthy(); + }); + await act(async () => { + fireEvent.click(screen.getByTestId("quick-scripts-manage")); + }); + await waitFor(() => { + expect(screen.getByTestId("scripts-modal")).toBeTruthy(); + }); + await act(async () => { + fireEvent.click(screen.getByTestId("run-script-build")); + }); + await waitFor(() => { + expect(screen.getByTestId("terminal-modal")).toBeTruthy(); + }); + + // Now — and only now — the machinery is allowed to exist. + expect(terminalLifecycle.mounts).toBe(1); + expect( + terminalLifecycle.renders.every((open) => open === true), + "App mounted TerminalModal with isOpen={false} at some point; every render while mounted must be an open one.", + ).toBe(true); + + const rendersWhileOpen = terminalLifecycle.renders.length; + + await act(async () => { + fireEvent.click(screen.getByTestId("terminal-close-btn")); + }); + await waitFor(() => { + expect(screen.queryByTestId("terminal-modal")).toBeNull(); + }); + + expect( + terminalLifecycle.unmounts, + "TERMINAL MOUNT REGRESSION: closing the terminal left TerminalModal mounted. Close must UNMOUNT it — that is what releases the WebSocket, the heartbeat interval, the xterm scrollback ring, and the WebGL context. Rendering null while staying mounted releases none of them.", + ).toBe(1); + expect( + terminalLifecycle.renders.length, + "App re-rendered TerminalModal after close; it must not be mounted at all while closed.", + ).toBe(rendersWhileOpen); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index c9573aa5b2..3399a987e3 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -64,6 +64,15 @@ vi.mock("../SubtaskBreakdownModal", () => ({ }, })); +/* +FNXC:Terminal 2026-07-26-19:50: +This mock is VESTIGIAL: AppModals does not render TerminalModal — App.tsx does, gated on +`modalManager.terminalOpen`. It is kept only to keep the heavy xterm module out of this file's graph if +an import path ever reaches it. It is deliberately NOT a shallow `isOpen ?
: null` stand-in, +because that shape is what made App's terminal mount/unmount invariant unverifiable (see the lifecycle +recorder in App.test.tsx). The guard below fails if the terminal ever moves into AppModals, so this mock +cannot silently become the thing that hides the invariant a second time. +*/ vi.mock("../TerminalModal", () => ({ TerminalModal: () => null, })); @@ -660,3 +669,16 @@ describe("AppModals", () => { }); }); }); + +describe("AppModals does not own the terminal", () => { + it("never renders TerminalModal (its mount lifecycle is App's, and is asserted there)", async () => { + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const source = readFileSync(resolve(fileURLToPath(import.meta.url), "../../AppModals.tsx"), "utf8"); + expect( + source.includes("TerminalModal"), + "TerminalModal moved into AppModals. Its mount/unmount invariant (a mounted-but-closed terminal holds a PTY WebSocket and a 45s heartbeat) is currently asserted against App.tsx in App.test.tsx, and the `() => null` mock in this file would hide it here. Move the lifecycle recorder over before landing this.", + ).toBe(false); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 4dbe4b273b..ea8f6626e7 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -760,6 +760,43 @@ describe("Column pagination", () => { expect(screen.getByRole("button", { name: /Load 25 more/i })).toBeTruthy(); }); + /* + FNXC:BoardColumnWindowing 2026-07-26-14:24: + The reset used to key on the `isSearchActive` boolean, so refining one broad query into another kept + the boolean true and carried an expanded window (up to hundreds of mounted TaskCards) into a brand-new + result set. These cases pin the corrected contract: a DIFFERENT search result set collapses back to + one screenful, while an unchanged one keeps the operator's expanded window (nothing to bound). + */ + it("collapses an expanded window when the search result set changes while search stays active", () => { + const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`)); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /Load 25 more/i })); + fireEvent.click(screen.getByRole("button", { name: /Load 25 more/i })); + expect(screen.getAllByTestId(/task-/)).toHaveLength(100); + + // Operator edits the query from one broad term to another: isSearchActive is STILL true, but the + // result set is entirely different. + const nextTasks = Array.from({ length: 130 }, (_, index) => makeTask(`FN-${String(index + 1).padStart(3, "0")}`)); + rerender(); + + expect(screen.getAllByTestId(/task-/)).toHaveLength(50); + }); + + it("keeps the expanded window across a re-render that yields the same search result set", () => { + const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`)); + const { rerender } = render(); + + fireEvent.click(screen.getByRole("button", { name: /Load 25 more/i })); + expect(screen.getAllByTestId(/task-/)).toHaveLength(75); + + // A poll hands back an equal-but-not-identical array; the window must not be yanked from under the + // operator. + rerender(); + + expect(screen.getAllByTestId(/task-/)).toHaveLength(75); + }); + it("preserves non-search pagination behavior when isSearchActive is not provided", () => { const tasks = Array.from({ length: 110 }, (_, index) => makeTask(`KB-${String(index + 1).padStart(3, "0")}`)); render(); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.closed-mount-cost.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.closed-mount-cost.test.tsx new file mode 100644 index 0000000000..8f626337f7 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TerminalModal.closed-mount-cost.test.tsx @@ -0,0 +1,168 @@ +/* +FNXC:Terminal 2026-07-26-19:05: +Half one of the App-unmounts-the-terminal invariant (App.tsx ~1927 renders TerminalModal only while +`modalManager.terminalOpen`). This file establishes the PREMISE that makes that conditional mount +load-bearing: a MOUNTED-but-closed TerminalModal is not free. `useTerminalSessions` and `useTerminal` are +called unconditionally at the top of the component, hundreds of lines above `if (!isOpen) return null`, +so `isOpen={false}` still bootstraps a PTY session, opens a WebSocket, and arms a 45s heartbeat interval. +A live socket plus a repeating timer on a backgrounded tab is a primary iOS Safari / Chrome Android +discard signal, and the discard is the white-splash reload. + +This is asserted against the REAL component with the REAL hooks — only the network edges (HTTP api, +WebSocket) are faked — because a shallow `isOpen ?
: null` stand-in cannot express the cost. The +companion assertion in App.test.tsx ("App must not mount TerminalModal while the terminal is closed") +depends on this cost being real; if this file ever fails because the hooks moved behind the early return, +that companion assertion has lost its point and both need revisiting together. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, act } from "@testing-library/react"; +import { TerminalModal } from "../TerminalModal"; +import * as useWorkspacesModule from "../../hooks/useWorkspaces"; + +const createTerminalSessionMock = vi.fn(); +const listTerminalSessionsMock = vi.fn(); + +vi.mock("../../api", () => ({ + createTerminalSession: (...args: unknown[]) => createTerminalSessionMock(...args), + killPtyTerminalSession: vi.fn().mockResolvedValue({ killed: true }), + listTerminalSessions: (...args: unknown[]) => listTerminalSessionsMock(...args), +})); +vi.mock("../../hooks/useWorkspaces", () => ({ useWorkspaces: vi.fn() })); +vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn(function TerminalMock() { return {}; }) })); +vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn(function FitAddonMock() { return {}; }) })); +vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: vi.fn(function WebLinksMock() { return {}; }) })); +vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: vi.fn(function WebglMock() { return {}; }) })); +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +/** The 45s cadence in useTerminal.ts. Duplicated because HEARTBEAT_INTERVAL is module-private. */ +const HEARTBEAT_INTERVAL_MS = 45_000; + +type FakeSocket = { + url: string; + readyState: number; + sent: string[]; + close: ReturnType; + onopen: (() => void) | null; +}; + +let sockets: FakeSocket[] = []; + +class FakeWebSocket { + static readonly OPEN = 1; + static readonly CLOSED = 3; + readonly OPEN = 1; + onopen: (() => void) | null = null; + onmessage: ((event: unknown) => void) | null = null; + onclose: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + readyState = 1; + sent: string[] = []; + close = vi.fn(() => { + this.readyState = 3; + }); + + constructor(public url: string) { + sockets.push(this as unknown as FakeSocket); + } + + send(data: string): void { + this.sent.push(data); + } +} + +let savedWebSocket: unknown; + +beforeEach(() => { + vi.useFakeTimers(); + sockets = []; + window.localStorage.clear(); + savedWebSocket = (globalThis as { WebSocket?: unknown }).WebSocket; + (globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket; + createTerminalSessionMock.mockReset().mockResolvedValue({ + sessionId: "session-closed-cost", + shell: "/bin/bash", + cwd: "/project", + }); + listTerminalSessionsMock.mockReset().mockResolvedValue([]); + vi.mocked(useWorkspacesModule.useWorkspaces).mockReturnValue({ + projectName: "kb", + workspaces: [], + loading: false, + error: null, + } as never); +}); + +afterEach(() => { + (globalThis as { WebSocket?: unknown }).WebSocket = savedWebSocket; + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +/** + * Flush the bootstrap chain (session create -> tab state -> WebSocket connect effect). + * Explicit timer advance rather than `waitFor`, which does not cooperate with fake timers here. + */ +async function settle(): Promise { + for (let i = 0; i < 5; i += 1) { + await act(async () => { + await vi.advanceTimersByTimeAsync(20); + }); + } +} + +describe("a mounted-but-closed TerminalModal still runs its expensive machinery", () => { + it("bootstraps a PTY session and opens a WebSocket even with isOpen={false}", async () => { + const { container } = render(); + + // Renders nothing — which is exactly why a shallow mock cannot tell this state from "not mounted". + expect(container.firstChild).toBeNull(); + + await settle(); + expect( + createTerminalSessionMock, + "TerminalModal no longer bootstraps a PTY session while closed. If the hooks moved behind the `if (!isOpen) return null` early return, the conditional mount in App.tsx is no longer load-bearing and App.test.tsx's companion assertion should be revisited rather than deleted.", + ).toHaveBeenCalled(); + + expect( + sockets.length, + "A closed-but-mounted TerminalModal opened no WebSocket. See the note above — this premise is what makes App's conditional mount matter.", + ).toBeGreaterThan(0); + expect(sockets[0].url).toContain("/api/terminal/ws"); + }); + + it("arms a repeating heartbeat interval that keeps firing while closed", async () => { + render(); + await settle(); + + const socket = sockets[0]; + expect(socket, "no WebSocket to open").toBeDefined(); + await act(async () => { + socket.onopen?.(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(HEARTBEAT_INTERVAL_MS * 2); + }); + + // Two pings with no user present and nothing rendered: background work on a hidden tab. + expect( + socket.sent.filter((frame) => frame.includes("ping")).length, + "A closed-but-mounted TerminalModal armed no heartbeat. The 45s timer is half of what made an always-mounted terminal a tab-discard signal.", + ).toBeGreaterThanOrEqual(2); + }); + + it("releases the socket when unmounted", async () => { + const { unmount } = render(); + await settle(); + const socket = sockets[0]; + + await act(async () => { + unmount(); + }); + + expect( + socket.close, + "Unmounting TerminalModal must close its PTY WebSocket; App relies on unmount to release it.", + ).toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.webgl-disposal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.webgl-disposal.test.tsx new file mode 100644 index 0000000000..b8bff117a7 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TerminalModal.webgl-disposal.test.tsx @@ -0,0 +1,372 @@ +/* +FNXC:Terminal 2026-07-26-18:10: +Guard for the WebGL-context release half of the mobile tab-retention work. `terminal-scrollback-floor.test.ts` +covers the scrollback constants; nothing covered the GL context, which is the other resident-set item +TerminalModal teardown is responsible for. + +Requirement: a live xterm holds a WebGL renderer owning a real GL context plus glyph-atlas textures. GL +contexts are a scarce process-wide resource that GC does not release promptly, so an orphaned one keeps +memory pressure high on iOS — and memory pressure is what makes the OS discard the backgrounded tab and +force the white-splash reload. Every teardown path must therefore dispose the ADDON explicitly, before +the terminal, not just call `terminal.dispose()` and trust xterm's AddonManager. + +There are five teardown paths (session switch, project switch, modal close, session-invalid swap, manual +reinit) plus unmount. They were once five hand-copied blocks and NONE disposed the WebGL addon, so this +file asserts the property per-path rather than once: the four paths reachable through the public +component surface are driven behaviorally below, and the structural guard at the bottom covers the +remainder by proving no teardown site can bypass the shared `disposeXtermInstance` helper. + +Ordering is asserted, not just occurrence: disposing the addon AFTER its terminal is already destroyed is +the shape that silently leaves the context attached. +*/ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { render, waitFor, act } from "@testing-library/react"; +import { TerminalModal } from "../TerminalModal"; +import * as useTerminalModule from "../../hooks/useTerminal"; +import * as useTerminalSessionsModule from "../../hooks/useTerminalSessions"; +import * as useWorkspacesModule from "../../hooks/useWorkspaces"; + +vi.mock("../../hooks/useTerminal", () => ({ useTerminal: vi.fn() })); +vi.mock("../../hooks/useTerminalSessions", () => ({ useTerminalSessions: vi.fn() })); +vi.mock("../../hooks/useWorkspaces", () => ({ useWorkspaces: vi.fn() })); +vi.mock("../../api", () => ({ + createTerminalSession: vi.fn().mockResolvedValue({ + sessionId: "session-a", + shell: "/bin/bash", + cwd: "/project", + }), + killPtyTerminalSession: vi.fn().mockResolvedValue({ killed: true }), + listTerminalSessions: vi.fn().mockResolvedValue([]), +})); +vi.mock("@xterm/xterm/css/xterm.css", () => ({})); + +/** Ordered record of teardown calls, so "addon disposed before terminal" is checkable. */ +let teardownLog: string[] = []; +/** Every WebglAddon the component constructed, in creation order. */ +let webglAddons: Array<{ dispose: ReturnType }> = []; +/** Every Terminal the component constructed, in creation order. */ +let terminals: Array<{ dispose: ReturnType }> = []; + +function createMockTerminal() { + const index = terminals.length; + const terminal = { + loadAddon: vi.fn(), + open: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + attachCustomKeyEventHandler: vi.fn(), + hasSelection: vi.fn(() => false), + getSelection: vi.fn(() => ""), + paste: vi.fn(), + dispose: vi.fn(() => teardownLog.push(`terminal:${index}`)), + write: vi.fn(), + clear: vi.fn(), + focus: vi.fn(), + refresh: vi.fn(), + options: { fontFamily: undefined, fontSize: 14, cursorStyle: "block", cursorBlink: true }, + cols: 80, + rows: 24, + }; + terminals.push(terminal); + return terminal; +} + +vi.mock("@xterm/xterm", () => ({ + Terminal: vi.fn(function TerminalMock() { + return createMockTerminal(); + }), +})); +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: vi.fn(function FitAddonMock() { + return { fit: vi.fn(), dispose: vi.fn() }; + }), +})); +vi.mock("@xterm/addon-web-links", () => ({ + WebLinksAddon: vi.fn(function WebLinksAddonMock() { + return { dispose: vi.fn() }; + }), +})); +vi.mock("@xterm/addon-webgl", () => ({ + WebglAddon: vi.fn(function WebglAddonMock() { + const index = webglAddons.length; + const addon = { + onContextLoss: vi.fn(), + dispose: vi.fn(() => teardownLog.push(`webgl:${index}`)), + }; + webglAddons.push(addon); + return addon; + }), +})); + +const mockUseTerminal = vi.mocked(useTerminalModule.useTerminal); +const mockUseTerminalSessions = vi.mocked(useTerminalSessionsModule.useTerminalSessions); +const mockUseWorkspaces = vi.mocked(useWorkspacesModule.useWorkspaces); + +/** Captured from useTerminal so the session-invalid teardown path can be fired directly. */ +let sessionInvalidHandler: (() => void) | null = null; + +function tab(sessionId: string) { + return { id: `tab-${sessionId}`, sessionId, title: "bash", isActive: true, createdAt: 0 }; +} + +function setActiveSession(sessionId: string): void { + const activeTab = tab(sessionId); + mockUseTerminalSessions.mockReturnValue({ + tabs: [activeTab], + activeTab, + isReady: true, + autoCreateDisabled: false, + bootstrapError: null, + createTab: vi.fn(), + closeTab: vi.fn(), + setActiveTab: vi.fn(), + updateTabTitle: vi.fn(), + restartActiveTab: vi.fn(), + retryBootstrap: vi.fn(), + replaceActiveTabSession: vi.fn().mockResolvedValue(undefined), + } as never); +} + +beforeEach(() => { + teardownLog = []; + webglAddons = []; + terminals = []; + sessionInvalidHandler = null; + // Desktop, non-touch: the WebGL addon is skipped outright on mobile viewports. + Object.defineProperty(window, "innerWidth", { value: 1280, writable: true, configurable: true }); + Object.defineProperty(window, "ontouchstart", { value: undefined, writable: true, configurable: true }); + Object.defineProperty(navigator, "maxTouchPoints", { value: 0, writable: true, configurable: true }); + Object.defineProperty(document, "fonts", { value: undefined, configurable: true }); + window.localStorage.clear(); + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as never); + mockUseTerminal.mockReturnValue({ + connectionStatus: "disconnected", + sendInput: vi.fn(), + resize: vi.fn(), + onData: vi.fn(() => vi.fn()), + onExit: vi.fn(() => vi.fn()), + onConnect: vi.fn(() => vi.fn()), + onScrollback: vi.fn(() => vi.fn()), + reconnect: vi.fn(), + onSessionInvalid: vi.fn((cb: () => void) => { + sessionInvalidHandler = cb; + return vi.fn(); + }), + } as never); + mockUseWorkspaces.mockReturnValue({ + projectName: "kb", + workspaces: [], + loading: false, + error: null, + } as never); + setActiveSession("session-a"); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Render an open modal and wait until the WebGL addon has actually attached. */ +async function renderWithLiveWebgl(props: { projectId?: string } = {}) { + const view = render(); + await waitFor(() => { + expect( + webglAddons.length, + "TerminalModal never attached a WebGL addon, so this file cannot prove anything about its disposal. Check the mobile/renderer-preference gate in the init effect before editing the assertions below.", + ).toBeGreaterThan(0); + }); + return view; +} + +/** + * The invariant every path shares: the addon at `addonIndex` was disposed, and it was disposed while its + * terminal was still alive. + */ +function expectGlContextReleasedBeforeTerminal(addonIndex: number, terminalIndex: number, path: string): void { + const addonAt = teardownLog.indexOf(`webgl:${addonIndex}`); + const terminalAt = teardownLog.indexOf(`terminal:${terminalIndex}`); + expect( + addonAt, + `WEBGL LEAK — ${path}: the WebGL addon was never disposed. Calling only terminal.dispose() leaves the GL context and its glyph-atlas textures resident; GC does not release GL contexts promptly, and that residency is what gets a backgrounded tab discarded on iOS. Route this teardown through disposeXtermInstance().`, + ).toBeGreaterThanOrEqual(0); + expect( + terminalAt, + `TERMINAL LEAK — ${path}: the xterm instance (and its multi-thousand-line scrollback ring) was never disposed.`, + ).toBeGreaterThanOrEqual(0); + expect( + addonAt, + `WEBGL DISPOSE ORDER — ${path}: the addon must be disposed BEFORE its terminal so it can detach from a live renderer. Disposing it after terminal.dispose() is the shape that silently leaves the context attached.`, + ).toBeLessThan(terminalAt); +} + +describe("TerminalModal releases the WebGL context on every teardown path", () => { + it("modal close", async () => { + const { rerender } = await renderWithLiveWebgl(); + + await act(async () => { + rerender(); + }); + + expectGlContextReleasedBeforeTerminal(0, 0, "modal close"); + }); + + it("unmount", async () => { + const { unmount } = await renderWithLiveWebgl(); + + await act(async () => { + unmount(); + }); + + expectGlContextReleasedBeforeTerminal(0, 0, "unmount"); + }); + + it("session switch", async () => { + const { rerender } = await renderWithLiveWebgl(); + + setActiveSession("session-b"); + await act(async () => { + rerender(); + }); + // The switch tears the old instance down and builds a new one. + await waitFor(() => { + expect(webglAddons.length).toBeGreaterThan(1); + }); + + expectGlContextReleasedBeforeTerminal(0, 0, "session switch"); + }); + + it("project switch", async () => { + const { rerender } = await renderWithLiveWebgl({ projectId: "proj-1" }); + + await act(async () => { + rerender(); + }); + await waitFor(() => { + expect(webglAddons.length).toBeGreaterThan(1); + }); + + expectGlContextReleasedBeforeTerminal(0, 0, "project switch"); + }); + + it("session-invalid swap", async () => { + await renderWithLiveWebgl(); + expect( + sessionInvalidHandler, + "useTerminal's onSessionInvalid was never subscribed; this path cannot be driven.", + ).not.toBeNull(); + + await act(async () => { + sessionInvalidHandler?.(); + }); + + expectGlContextReleasedBeforeTerminal(0, 0, "session-invalid swap"); + }); +}); + +/* +FNXC:Terminal 2026-07-26-18:15: +Structural backstop for the paths that cannot be driven through the public surface (manual reinit is +behind an init-error affordance) and, more importantly, for paths that do not exist yet. The behavioral +cases above prove today's five sites release the context; they cannot prove the SIXTH one somebody adds +next month does. The enforceable invariant is that `xtermRef` is only ever disposed inside +`disposeXtermInstance`, which is the single place the addon is released — so a new teardown block +physically cannot bypass it. +*/ +const TERMINAL_MODAL_PATH = resolve(fileURLToPath(import.meta.url), "../../TerminalModal.tsx"); + +/** + * Strip block comments and whole-line `//` comments before counting call sites. + * + * FNXC:Terminal 2026-07-26-18:35: + * Required, not cosmetic: both terminal files document their disposal ordering in FNXC prose that quotes + * `term.dispose()` verbatim, so a raw text scan finds the COMMENT before the code and reports a false + * ordering violation (and a false second call site). Same reason the engine's legacy-tombstone guard + * strips comments first. Only whole-line `//` is removed so a `https://` inside a string is left alone. + */ +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .split("\n") + .filter((line) => !line.trimStart().startsWith("//")) + .join("\n"); +} + +describe("TerminalModal teardown routes through the shared disposer", () => { + const source = readFileSync(TERMINAL_MODAL_PATH, "utf8"); + + it("disposes the WebGL addon before the terminal inside disposeXtermInstance", () => { + const helper = /const disposeXtermInstance = useCallback\(\(\) => \{([\s\S]*?)\n {2}\}, \[\]\);/.exec(source); + expect( + helper, + `webgl-disposal: could not locate \`disposeXtermInstance\` in ${TERMINAL_MODAL_PATH}. It was renamed or restructured — re-point this guard rather than deleting it; it is the only thing preventing a new teardown path from leaking a GL context.`, + ).not.toBeNull(); + const body = helper![1]; + const webglAt = body.indexOf("webglAddonRef.current.dispose()"); + const terminalAt = body.indexOf("xtermRef.current.dispose()"); + expect(webglAt, "disposeXtermInstance no longer disposes the WebGL addon.").toBeGreaterThanOrEqual(0); + expect(terminalAt, "disposeXtermInstance no longer disposes the terminal.").toBeGreaterThanOrEqual(0); + expect( + webglAt, + "disposeXtermInstance must dispose the WebGL addon BEFORE the terminal, so the addon detaches from a live renderer.", + ).toBeLessThan(terminalAt); + expect(body, "disposeXtermInstance must null the addon ref so a stale addon is never re-disposed.").toContain( + "webglAddonRef.current = null", + ); + }); + + it("has no teardown site that disposes the terminal outside the shared disposer", () => { + const occurrences = stripComments(source).split("xtermRef.current.dispose()").length - 1; + expect( + occurrences, + `WEBGL LEAK RISK: \`xtermRef.current.dispose()\` appears ${occurrences} times in ${TERMINAL_MODAL_PATH}, but it must appear exactly once — inside disposeXtermInstance. A second call site is a teardown path that destroys the terminal without releasing its GL context, which is exactly the drift (four hand-copied blocks, none disposing WebGL) this helper was introduced to end. Call disposeXtermInstance() instead.`, + ).toBe(1); + }); + + /* + FNXC:Terminal 2026-07-26-18:25: + Sibling surface. SessionTerminal is the second xterm host and holds its own GL context; its teardown is + the init effect's cleanup (deps: sessionId, readOnly, mode, projectId), so session switch, mode switch, + project switch, and unmount all funnel through that one block. It is guarded structurally here rather + than behaviorally because there is exactly one site and the failure mode is a future edit that reorders + or drops the addon dispose — not a missing call path. + */ + it("SessionTerminal disposes its WebGL addon before its terminal in the only teardown block", () => { + const sessionTerminalPath = resolve(fileURLToPath(import.meta.url), "../../SessionTerminal.tsx"); + const sessionSource = stripComments(readFileSync(sessionTerminalPath, "utf8")); + const webglAt = sessionSource.indexOf("webglAddonRef.current.dispose()"); + expect( + webglAt, + `WEBGL LEAK — ${sessionTerminalPath}: the CLI-agent session terminal no longer disposes its WebGL addon on teardown. Its GL context then outlives the session, which is the iOS memory-pressure source that gets the backgrounded tab discarded.`, + ).toBeGreaterThanOrEqual(0); + const termDisposeAt = sessionSource.indexOf("term.dispose()"); + expect(termDisposeAt, "SessionTerminal no longer disposes its terminal on teardown.").toBeGreaterThanOrEqual(0); + expect( + webglAt, + "SessionTerminal must dispose the WebGL addon BEFORE the terminal, so the addon detaches from a live renderer.", + ).toBeLessThan(termDisposeAt); + expect( + sessionSource.split("term.dispose()").length - 1, + "SessionTerminal must have exactly one terminal-teardown site; a second one is a path that can destroy the terminal without releasing its GL context.", + ).toBe(1); + }); + + it("keeps every teardown path wired to the disposer", () => { + // Session/project switch, modal close, session-invalid swap, manual reinit, unmount. + const callSites = stripComments(source).split("disposeXtermInstance()").length - 1; + expect( + callSites, + `webgl-disposal: expected at least the five known teardown call sites plus unmount to call disposeXtermInstance(); found ${callSites}. If a path was removed, remove its behavioral case above too rather than lowering this number silently.`, + ).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/agent-detail-log-history.test.tsx b/packages/dashboard/app/components/__tests__/agent-detail-log-history.test.tsx index 69829fdba7..152e865686 100644 --- a/packages/dashboard/app/components/__tests__/agent-detail-log-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/agent-detail-log-history.test.tsx @@ -25,6 +25,7 @@ import type { AgentLogEntry } from "@fusion/core"; import { createMockAgent, mockFetchAgent, + mockFetchAgentLogsWithMeta, mockFetchAgentRunLogs, mockFetchAgentRuns, mockFetchAgentRunDetail, @@ -192,3 +193,156 @@ describe("AgentDetailView — agent log history is windowed, not discarded", () expect(screen.getByText("entry-0")).toBeInTheDocument(); }); }); + +/* +FNXC:AgentLogResync 2026-07-26-18:40: +The task-log branch of `loadLogs` is BOTH the initial load and the SSE-reconnect heal, and it replaced the +buffer with a fixed 100-entry page. An agent that had streamed 480 lines therefore lost 380 of them at the +first reopen after the hidden-tab suspend — and because this view has no server-paging path (the "Load +older" button walks only the array already in memory), `hiddenCount` collapsed to 0, the button vanished, +and the truncated log was presented as the complete one. Silent, unrecoverable, and indistinguishable from +a short log. + +These tests model the server as a growing array and assert the invariant end-to-end: after a reconnect the +buffer still reaches entry 0 AND contains the lines emitted inside the gap. +*/ +describe("AgentDetailView — task-log reconnect reconciles instead of replacing", () => { + const TASK_ID = "FN-001"; + + /** Server-side log; `fetchAgentLogsWithMeta` serves its newest `limit` entries, like the real route. */ + let serverLog: AgentLogEntry[] = []; + let requestedLimits: number[] = []; + + function taskEntry(index: number): AgentLogEntry { + return { + timestamp: new Date(Date.UTC(2024, 0, 1, 0, 0, index)).toISOString(), + taskId: TASK_ID, + text: `entry-${index}`, + type: "text", + }; + } + + beforeEach(() => { + setupAgentDetailMocks(); + serverLog = Array.from({ length: 100 }, (_, i) => taskEntry(i)); + requestedLimits = []; + mockFetchAgent.mockResolvedValue(createMockAgent({ taskId: TASK_ID })); + mockFetchAgentLogsWithMeta.mockImplementation(async (_taskId, _projectId, opts) => { + const limit = opts?.limit ?? 100; + requestedLimits.push(limit); + return { + entries: serverLog.slice(Math.max(0, serverLog.length - limit)), + total: serverLog.length, + hasMore: serverLog.length > limit, + }; + }); + vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { + status: 200, + headers: { "Content-Type": "application/json" }, + }))); + }); + + async function openLogsTab() { + render(); + await waitFor(() => expect(screen.getByText("Logs")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Logs")); + await waitFor(() => expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument()); + } + + function taskStreamOptions() { + const call = mockSubscribeSse.mock.calls.find(([url]) => url === `/api/tasks/${TASK_ID}/logs/stream`); + expect(call, "the task log stream must be subscribed").toBeTruthy(); + return call![1] as { + events?: Record void>; + onReconnect?: () => void; + }; + } + + it("keeps streamed history and splices in the lines missed during the suspend window", async () => { + await openLogsTab(); + await waitFor(() => expect(renderedEntryTexts()).toHaveLength(100)); + + // 380 lines stream in while the tab is visible: the buffer grows to 480 and the server agrees. + const options = taskStreamOptions(); + await act(async () => { + for (let i = 100; i < 480; i++) { + const entry = taskEntry(i); + serverLog.push(entry); + options.events?.["agent:log"]?.({ data: JSON.stringify(entry) } as MessageEvent); + } + }); + await waitFor(() => expect(renderedEntryTexts()).toHaveLength(480)); + + // Hidden 60s+: the channel is torn down and entries 480..484 are emitted with nobody listening. + for (let i = 480; i < 485; i++) serverLog.push(taskEntry(i)); + + expect(options.onReconnect, "log subscriptions must define onReconnect").toBeTypeOf("function"); + await act(async () => { + options.onReconnect?.(); + await Promise.resolve(); + }); + + // The missed lines arrive… + await waitFor(() => expect(screen.getByText("entry-484")).toBeInTheDocument()); + expect(screen.getByText("entry-480")).toBeInTheDocument(); + // …and NOT at the price of the 380 lines the reader already had. + expect(screen.getByText("entry-0")).toBeInTheDocument(); + expect(screen.getByText("entry-99")).toBeInTheDocument(); + expect(renderedEntryTexts()).toHaveLength(485); + + // The resync page must be sized to the buffer; a fixed 100 is what silently dropped 380 entries. + expect(requestedLimits[requestedLimits.length - 1]).toBeGreaterThanOrEqual(480); + }); + + it("marks a gap visibly when the missed window is larger than the buffer can prove", async () => { + await openLogsTab(); + await waitFor(() => expect(renderedEntryTexts()).toHaveLength(100)); + + // The whole server log is replaced by newer entries: nothing the reader holds is in the fresh page, + // so continuity cannot be proven. The reader must be TOLD, not silently handed a fresh page. + serverLog = Array.from({ length: 120 }, (_, i) => taskEntry(5000 + i)); + + await act(async () => { + taskStreamOptions().onReconnect?.(); + await Promise.resolve(); + }); + + await waitFor(() => expect(screen.getByText("entry-5119")).toBeInTheDocument()); + expect(screen.getByText(/Log stream reconnected/)).toBeInTheDocument(); + }); + + it("every SSE subscription in the detail view declares onReconnect", async () => { + await openLogsTab(); + + // The /api/events subscription drives the state badge and approval indicator from events alone; it + // was the one subscription missed when onReconnect was added to the three log channels. + const eventsCall = mockSubscribeSse.mock.calls.find(([url]) => url.startsWith("/api/events")); + expect(eventsCall, "the detail view must subscribe to /api/events").toBeTruthy(); + + for (const [url, options] of mockSubscribeSse.mock.calls) { + expect( + (options as { onReconnect?: () => void }).onReconnect, + `subscription ${url} must declare onReconnect (SSE replays nothing after the hidden-tab suspend)`, + ).toBeTypeOf("function"); + } + }); + + it("/api/events reconnect refetches the agent so a state change during the gap is not missed", async () => { + await openLogsTab(); + + const eventsCall = mockSubscribeSse.mock.calls.find(([url]) => url.startsWith("/api/events")); + const options = eventsCall![1] as { onReconnect?: () => void }; + const callsBefore = mockFetchAgent.mock.calls.length; + + // While hidden the agent went to `error`; no agent:updated event survives the suspend. + mockFetchAgent.mockResolvedValue(createMockAgent({ taskId: TASK_ID, state: "error" })); + + await act(async () => { + options.onReconnect?.(); + await Promise.resolve(); + }); + + await waitFor(() => expect(mockFetchAgent.mock.calls.length).toBeGreaterThan(callsBefore)); + await waitFor(() => expect(screen.getAllByText("error").length).toBeGreaterThan(0)); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/list-view-windowing.test.tsx b/packages/dashboard/app/components/__tests__/list-view-windowing.test.tsx index d59f6d34df..1720f140a0 100644 --- a/packages/dashboard/app/components/__tests__/list-view-windowing.test.tsx +++ b/packages/dashboard/app/components/__tests__/list-view-windowing.test.tsx @@ -41,8 +41,13 @@ vi.mock("../TaskDetailModal", () => ({ ), })); +const confirmMocks = vi.hoisted(() => ({ + confirm: vi.fn(), + confirmWithChoice: vi.fn(), +})); + vi.mock("../../hooks/useConfirm", () => ({ - useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }), + useConfirm: () => confirmMocks, })); const PROJECT_ID = "proj-windowing"; @@ -98,6 +103,10 @@ function renderedTaskIds(): string[] { beforeEach(() => { localStorage.clear(); + confirmMocks.confirm.mockReset(); + confirmMocks.confirm.mockResolvedValue(false); + confirmMocks.confirmWithChoice.mockReset(); + confirmMocks.confirmWithChoice.mockResolvedValue("cancel"); }); describe("ListView render windowing", () => { @@ -152,3 +161,82 @@ describe("ListView render windowing", () => { expect(renderedTaskIds()).toContain(FAR_TASK_ID); }); }); + +/* +FNXC:ListViewSelectAll 2026-07-26-14:40: +The header checkbox says "Select all visible tasks" and arms DESTRUCTIVE bulk actions (delete, column +move). Render windowing made that label false: it flattened the full filtered set, so 50 rendered rows +armed 200 tasks. These cases pin the corrected contract at the point that matters — the count a bulk +action actually confirms — not merely at the internal selection state. +*/ +describe("ListView select-all under render windowing", () => { + function enterBulkEdit() { + act(() => { + fireEvent.click(screen.getByRole("button", { name: /Bulk Edit/i })); + }); + } + + function selectAll() { + act(() => { + fireEvent.click(screen.getByLabelText("Select all visible tasks")); + }); + } + + it("selects only the rendered window, not the whole filtered set", () => { + renderList(); + enterBulkEdit(); + + const rendered = renderedTaskIds(); + expect(rendered).toHaveLength(INITIAL_WINDOW); + + selectAll(); + + const persisted: string[] = JSON.parse( + localStorage.getItem(scopedKey("kb-dashboard-selected-tasks", PROJECT_ID)) ?? "[]", + ); + expect(persisted.sort()).toEqual([...rendered].sort()); + expect(persisted).toHaveLength(INITIAL_WINDOW); + expect(screen.getAllByText(`${INITIAL_WINDOW} selected`).length).toBeGreaterThan(0); + }); + + it("confirms a bulk delete against the rendered rows only", async () => { + renderList(); + enterBulkEdit(); + selectAll(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: /Delete selected/i })); + }); + + expect(confirmMocks.confirm).toHaveBeenCalledTimes(1); + const { message } = confirmMocks.confirm.mock.calls[0][0] as { message: string }; + expect(message).toContain(String(INITIAL_WINDOW)); + expect(message).not.toContain(String(TOTAL_TASKS)); + }); + + it("grows the select-all target as the window is expanded", () => { + renderList(); + enterBulkEdit(); + + act(() => { + fireEvent.click(screen.getByRole("button", { name: /Load 25 more/i })); + }); + selectAll(); + + const persisted: string[] = JSON.parse( + localStorage.getItem(scopedKey("kb-dashboard-selected-tasks", PROJECT_ID)) ?? "[]", + ); + expect(persisted).toHaveLength(INITIAL_WINDOW + INCREMENT); + expect(persisted.sort()).toEqual([...renderedTaskIds()].sort()); + }); + + it("reports checked, not indeterminate, once the rendered window is fully selected", () => { + renderList(); + enterBulkEdit(); + selectAll(); + + const checkbox = screen.getByLabelText("Select all visible tasks") as HTMLInputElement; + expect(checkbox.checked).toBe(true); + expect(checkbox.indeterminate).toBe(false); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/terminal-scrollback-floor.test.ts b/packages/dashboard/app/components/__tests__/terminal-scrollback-floor.test.ts new file mode 100644 index 0000000000..6644ead3c3 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/terminal-scrollback-floor.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/* +FNXC:TerminalScrollback 2026-07-26-14:05: +The two xterm scrollback caps are load-bearing and were, until this test, completely uncovered. + +A previous memory-pressure pass cut both to 2000 lines, justified by the claim that "the server replays its own scrollback on reconnect, so reachable history is unchanged". That claim is FALSE in two independent ways: + +1. Server replay happens ONLY AT ATTACH TIME (one `scrollback` frame on connect). While a session stays attached, the client ring is the only history that exists; nothing re-fetches a line the client ring has evicted. Evicted history is permanently unreachable, not merely uncached. +2. Even at attach time the server ring is SMALLER than the client ring, so it cannot back-stop it. TerminalModal's PTY ring is `MAX_SCROLLBACK_SIZE` (CHARACTERS, not lines) in `packages/dashboard/src/terminal-service.ts`; SessionTerminal's CLI-agent ring is `DEFAULT_SCROLLBACK_BYTES` in `packages/engine/src/cli-agent/session-manager.ts`. + +So this test encodes the RELATIONSHIP, not the literals: each client ring must hold more lines than its own server ring could ever replay. Both server capacities are read from their real source files, so shrinking a server ring relaxes this guard honestly and growing one tightens it — a future optimizer cannot satisfy it by editing a copied number here. + +Why source-text parsing instead of imports: importing TerminalModal.tsx / SessionTerminal.tsx pulls xterm and the WebGL addon into the worker, and importing terminal-service.ts pulls node-pty via @fusion/engine. This is a constants/invariant test and must stay a few milliseconds with no DOM and no terminal. `MAX_SCROLLBACK_SIZE` is module-private anyway. Each read below FAILS LOUDLY if its constant is renamed or moved, so the guard cannot decay into a silent no-op. +*/ + +const HERE = resolve(fileURLToPath(import.meta.url), ".."); +const DASHBOARD_ROOT = resolve(HERE, "../../.."); +const REPO_PACKAGES = resolve(DASHBOARD_ROOT, ".."); + +/* +FNXC:TerminalScrollback 2026-07-26-14:05: +Character/byte capacity converts to a LINE count only through an assumed density. 64 bytes/line is the conservative end of "typical terminal line" — conservative here means it yields a HIGHER server line count, which makes the requirement on the client ring STRICTER. It also reproduces the ~600-800-line estimate previously recorded for the 50000-character PTY ring (50000/64 = 781). Raising this constant to weaken a failing assertion is the appeasement this guard exists to prevent; if a real measurement says lines are wider, change it with the measurement cited. +*/ +const TYPICAL_LINE_BYTES = 64; + +/* +FNXC:TerminalScrollback 2026-07-26-14:05: +Absolute floor, independent of the server-ring relationship. Even if someone shrinks a server ring to near zero (which would make the relational assertion trivially satisfiable), a terminal surface with less than this much scrollback is not a usable debugging surface for agent sessions. 2000 is the value the FALSE-premise memory pass landed on; treat it as the hard floor beneath which no optimization may go. +*/ +const ABSOLUTE_MIN_CLIENT_SCROLLBACK_LINES = 2000; + +function readSource(absolutePath: string): string { + const source = readFileSync(absolutePath, "utf8"); + expect( + source.length, + `terminal-scrollback-floor: ${absolutePath} is empty or unreadable. This guard cannot verify the scrollback invariant; fix the path rather than deleting the test.`, + ).toBeGreaterThan(0); + return source; +} + +/** Extract a numeric constant, evaluating a simple `A * B` product form. */ +function extractNumericConstant(source: string, name: string, absolutePath: string): number { + const match = new RegExp(`\\b${name}\\s*=\\s*([0-9_]+)(?:\\s*\\*\\s*([0-9_]+))?`).exec(source); + expect( + match, + `terminal-scrollback-floor: could not find \`${name}\` in ${absolutePath}. It was renamed, moved, or made non-literal. Do NOT delete this guard — re-point it. It is the only thing asserting that the client-side xterm scrollback ring exceeds what the server can replay, and the server replays ONLY at attach time.`, + ).not.toBeNull(); + const parsed = Number(match![1].replace(/_/g, "")) * (match![2] ? Number(match![2].replace(/_/g, "")) : 1); + expect( + Number.isFinite(parsed) && parsed > 0, + `terminal-scrollback-floor: parsed a non-positive value for \`${name}\` in ${absolutePath}.`, + ).toBe(true); + return parsed; +} + +const SURFACES = [ + { + label: "TerminalModal (project PTY terminal)", + clientFile: resolve(DASHBOARD_ROOT, "app/components/TerminalModal.tsx"), + clientConstant: "TERMINAL_SCROLLBACK_LINES", + serverFile: resolve(DASHBOARD_ROOT, "src/terminal-service.ts"), + serverConstant: "MAX_SCROLLBACK_SIZE", + serverUnit: "characters", + }, + { + label: "SessionTerminal (CLI-agent session terminal)", + clientFile: resolve(DASHBOARD_ROOT, "app/components/SessionTerminal.tsx"), + clientConstant: "TERMINAL_SCROLLBACK_LINES", + serverFile: resolve(REPO_PACKAGES, "engine/src/cli-agent/session-manager.ts"), + serverConstant: "DEFAULT_SCROLLBACK_BYTES", + serverUnit: "bytes", + }, +] as const; + +describe("terminal scrollback floor", () => { + for (const surface of SURFACES) { + describe(surface.label, () => { + const clientLines = extractNumericConstant( + readSource(surface.clientFile), + surface.clientConstant, + surface.clientFile, + ); + const serverCapacity = extractNumericConstant( + readSource(surface.serverFile), + surface.serverConstant, + surface.serverFile, + ); + const serverReplayableLines = Math.floor(serverCapacity / TYPICAL_LINE_BYTES); + + it("keeps a client ring larger than the server can ever replay", () => { + expect( + clientLines, + [ + `SCROLLBACK REGRESSION — ${surface.label}.`, + `${surface.clientConstant} is ${clientLines} lines, but the server ring (${surface.serverConstant} = ${serverCapacity} ${surface.serverUnit} in ${surface.serverFile}) can replay about ${serverReplayableLines} lines at ~${TYPICAL_LINE_BYTES} ${surface.serverUnit}/line.`, + "The client ring MUST exceed that, because the server replays its scrollback ONLY AT ATTACH TIME — while a session stays attached, the client ring is the only history that exists, and every line it evicts is permanently unreachable to the user.", + "If you are lowering this to reduce mobile memory pressure: that trade was already made once on the false premise that the server backs up the client. It does not. Lower the SERVER-side cap first, or find memory elsewhere.", + ].join(" "), + ).toBeGreaterThan(serverReplayableLines); + }); + + it("stays above the absolute usability floor", () => { + expect( + clientLines, + `SCROLLBACK REGRESSION — ${surface.label}: ${surface.clientConstant} is ${clientLines}, below the hard floor of ${ABSOLUTE_MIN_CLIENT_SCROLLBACK_LINES} lines. This floor holds even if the server ring shrinks; a terminal with less scrollback than this cannot be used to debug an agent session. See the FNXC block at the top of ${surface.clientFile}.`, + ).toBeGreaterThanOrEqual(ABSOLUTE_MIN_CLIENT_SCROLLBACK_LINES); + }); + + it("actually passes the constant to xterm (the cap is wired, not vestigial)", () => { + expect( + readSource(surface.clientFile), + `terminal-scrollback-floor: ${surface.clientFile} no longer passes \`scrollback: ${surface.clientConstant}\` to the xterm Terminal constructor. The constant would then be dead and the real ring would silently fall back to xterm's 1000-line default.`, + ).toContain(`scrollback: ${surface.clientConstant}`); + }); + }); + } +}); diff --git a/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx index 2075a4659c..50370d328c 100644 --- a/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/MissionControlPanel.test.tsx @@ -54,6 +54,15 @@ function activeSession(id: string, overrides: Partial { apiMock.mockReset(); subscribeMock.mockClear(); @@ -64,6 +73,7 @@ beforeEach(() => { }); afterEach(() => { + setVisibility("visible"); vi.runOnlyPendingTimers(); vi.useRealTimers(); }); @@ -247,3 +257,101 @@ describe("MissionControlPanel — KTD5 push + poll convergence", () => { expect(screen.getByTestId("mission-control-error")).toBeTruthy(); }); }); + +/* +FNXC:MobileTabRetention 2026-07-26-17:05: +`useLiveSnapshot` does NOT use the shared `useVisibilityAwarePoll` primitive — it hand-rolls its own +`visibilitychange` listener because the poll is self-managing (each response decides whether to re-arm). +Consequence: it is invisible to `visibility-poll-gating.test.ts`'s sampled surfaces AND to the shared +hook's unit tests, so until this block existed a regression that reintroduced background polling on a +backgrounded tab would have passed the entire suite. A 5s fetch loop on a hidden tab is a primary iOS +Safari / Chrome Android page-discard signal, and the discard is the white-splash reload on return. + +Two independent behaviors are asserted, because there are two independent ways to reintroduce the leak: +1. The hidden edge must CLEAR the running interval (the listener's `stopPolling`). +2. A refetch that completes while hidden must not RE-ARM it (the `documentVisible` gate in `load`'s + `finally`). Deleting either one alone leaves the other passing, so both are tested separately. +The visible edge must then refetch exactly once and resume polling — suspension that never resumes is +the same silent-staleness defect wearing different clothes. +*/ +describe("MissionControlPanel — hidden-tab poll gating", () => { + it("stops polling while the document is hidden and fires no background fetches", async () => { + apiMock.mockResolvedValue( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + // In-flight work: the interval is armed and a tick fetches. + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS); + }); + await flush(); + expect(apiMock.mock.calls.length).toBeGreaterThan(1); + + setVisibility("hidden"); + await flush(); + + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it("does not re-arm the poll interval from a refetch that completes while hidden", async () => { + apiMock.mockResolvedValue( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + setVisibility("hidden"); + await flush(); + + // An SSE push (or the reconnect resync) can still land in the window before the bus itself + // suspends. Its response reports work in-flight — but a hidden tab must not schedule a timer. + await act(async () => { + sseHandlers["run:created"]?.({}); + }); + await flush(); + + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + expect(apiMock).not.toHaveBeenCalled(); + }); + + it("refetches exactly once on the hidden -> visible edge and re-arms polling", async () => { + apiMock.mockResolvedValue( + snapshot({ activeSessions: 1, activeNodes: 1, sessions: [activeSession("s1")] }), + ); + render(); + await flush(); + + setVisibility("hidden"); + await flush(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS * 3); + }); + await flush(); + + apiMock.mockClear(); + setVisibility("visible"); + await flush(); + + // Exactly one refresh on the edge — not zero (stale panel) and not a burst. + expect(apiMock).toHaveBeenCalledTimes(1); + + // ...and polling is live again, so the panel keeps converging while in view. + apiMock.mockClear(); + await act(async () => { + vi.advanceTimersByTime(LIVE_POLL_INTERVAL_MS); + }); + await flush(); + expect(apiMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx index 2ba66dded3..1d2cd471c5 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx @@ -86,6 +86,13 @@ const BACK_ONLINE_RELOAD_DELAY_MS = 3000; // forever with every control disabled. const RESTART_WAIT_TIMEOUT_MS = 90_000; const BOTTOM_FOLLOW_THRESHOLD_PX = 50; +/* +FNXC:SystemPanelJobStreamResync 2026-07-26-16:18: +Minimum spacing between authoritative job-state refetches triggered by SSE reconnect/error. The bus +retries a broken job stream with backoff, and a job whose server process is gone errors on every +attempt, so the resync must be bounded rather than one REST round-trip per error event. +*/ +const RECONCILE_THROTTLE_MS = 3000; function isNearBottom(container: HTMLElement): boolean { return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD_PX; @@ -134,6 +141,19 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr const [restartPhase, setRestartPhase] = useState(null); const prevPidRef = useRef(null); + /* + FNXC:SystemPanelJobStreamResync 2026-07-26-16:20: + Latest-value refs for the job-stream resync path. The SSE callbacks below run outside React's + render cycle (a hidden-suspend resume fires them before any re-render), so they must read the + current job/info from refs rather than from a stale effect closure. + */ + const jobRef = useRef(null); + const infoRef = useRef(null); + /** Job ids whose terminal outcome was already applied — the `end` event and a resync can race. */ + const terminalAppliedJobIdsRef = useRef>(new Set()); + const reconcileInFlightRef = useRef(false); + const lastReconcileAtRef = useRef(0); + const [logsOpen, setLogsOpen] = useState(false); const [logEntries, setLogEntries] = useState([]); const logOutputRef = useRef(null); @@ -212,6 +232,135 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr }; }, [loadInfo]); + useEffect(() => { + jobRef.current = job; + }, [job]); + useEffect(() => { + infoRef.current = info; + }, [info]); + + /* + FNXC:SystemPanelJobStreamResync 2026-07-26-16:24: + Single writer for a job's terminal outcome, shared by the `end` SSE event and the reconnect + resync below. Applying it twice would double-toast and re-arm the restart wait, and the two paths + legitimately race (the server writes `end` then closes the socket, which surfaces as an + EventSource error that also triggers a resync), so it is idempotent per job id. + */ + const applyJobTerminalState = useCallback( + (snapshot: SystemRebuildJobSnapshot) => { + if (terminalAppliedJobIdsRef.current.has(snapshot.id)) { + setJob(snapshot); + return; + } + terminalAppliedJobIdsRef.current.add(snapshot.id); + setJob(snapshot); + if (snapshot.status === "succeeded" && snapshot.restartScheduled) { + prevPidRef.current = infoRef.current?.pid ?? null; + setRestartPhase("waiting"); + } else if (snapshot.status === "succeeded") { + const successMsg = + snapshot.kind === "fn-binary" && snapshot.scope === "link-local" + ? t("systemControls.fnLinkLocalSucceeded", "Local fn binary built and linked") + : snapshot.kind === "fn-binary" && snapshot.scope === "use-global" + ? t("systemControls.fnUseGlobalSucceeded", "Switched default fn to global npm install") + : t("systemControls.rebuildSucceeded", "Rebuild finished successfully"); + toast(successMsg, "success"); + } else { + toast(t("systemControls.rebuildFailed", "Job failed — see output for details"), "error"); + } + }, + [t, toast], + ); + + /* + FNXC:SystemPanelJobStreamResync 2026-07-26-16:30: + Missed-`end` recovery for the rebuild/fn-binary job stream. Verified server behavior + (register-system-routes.ts GET /system/jobs/:id/stream): on connect it replays every buffered + line from `Last-Event-ID + 1` (0 for a fresh EventSource) and, when the job is no longer running, + writes a terminal `end` snapshot and closes. So the stream IS replay-safe for a job whose SERVER + PROCESS SURVIVED the disconnect — that part of the earlier "genuinely replay-safe" review note is + correct. + It is NOT replay-safe in the case this panel exists for: a "Rebuild & restart" job. `jobsById` is + process-local in-memory state, so after the rebuild restarts the server the stream URL answers 404 + and no `end` is ever delivered. The `end` handler is the only writer of the terminal snapshot and + of restartPhase="waiting", and the effect's deps ([job?.id, job?.status]) never change while the + panel believes the job is running, so the panel would show "running" forever with no toast and no + auto-reload. The 60s hidden-suspend makes this routine: an operator switches away during a + rebuild, the socket is torn down, and the job finishes (and restarts the server) while gone. + Note: a 404 reconnect never fires `onReconnect` (EventSource has no `open`), only `onError` — + which is why both callbacks feed this reconcile. + Recovery is authoritative-REST-first and never fabricates a verdict: + - server still knows the job → adopt its snapshot (lines + terminal state); + - job gone AND the PID changed → the process restarted; drop the unrecoverable job (the new + process has no record of it) and, for a restart-after job, enter the "back online" reload path; + - job gone with the same PID → report an UNKNOWN outcome, never a fabricated success. + */ + const reconcileJobAfterStreamGap = useCallback(async () => { + const active = jobRef.current; + if (!active || active.status !== "running") return; + if (reconcileInFlightRef.current) return; + // EventSource errors can repeat while the bus backs off; bound the REST fan-out. + const now = Date.now(); + if (now - lastReconcileAtRef.current < RECONCILE_THROTTLE_MS) return; + lastReconcileAtRef.current = now; + reconcileInFlightRef.current = true; + const priorPid = infoRef.current?.pid; + try { + let snapshot: SystemRebuildJobSnapshot | null = null; + try { + ({ job: snapshot } = await fetchCurrentSystemRebuild()); + } catch { + // Server unreachable — it is probably mid-restart. Allow an immediate retry on the next + // reconnect/error rather than burning the throttle window. + lastReconcileAtRef.current = 0; + return; + } + if (jobRef.current?.id !== active.id) return; + + if (snapshot && snapshot.id === active.id) { + const lines = snapshot.lines; + if (lines) { + // Authoritative buffer: reseed the dedupe index from ALL indexes so a later stream + // replay cannot re-append a line the cap trimmed out of the rendered tail. + seenJobLineIndexesRef.current = new Set(lines.map((line) => line.i)); + setJobLines(capLogEntries(lines, LOG_VIEW_CAP)); + } + if (snapshot.status !== "running") applyJobTerminalState(snapshot); + return; + } + + const nextInfo = await fetchSystemInfo().catch(() => null); + if (!nextInfo || jobRef.current?.id !== active.id) return; + setInfo(nextInfo); + if (priorPid !== undefined && nextInfo.pid !== priorPid) { + setJob(null); + setJobLines([]); + seenJobLineIndexesRef.current = new Set(); + if (active.restartAfter) { + prevPidRef.current = nextInfo.pid; + setRestartPhase("back"); + } else { + toast( + t("systemControls.jobLostToRestart", "The server restarted while the job was streaming — its output is gone"), + "warning", + ); + } + return; + } + + // Same process, job unknown: we cannot prove success or failure. Say so. + const unknown = t( + "systemControls.jobOutcomeUnknown", + "Lost the job stream before a result arrived — the outcome is unknown", + ); + terminalAppliedJobIdsRef.current.add(active.id); + setJob({ ...active, status: "failed", finishedAt: Date.now(), error: unknown }); + toast(unknown, "warning"); + } finally { + reconcileInFlightRef.current = false; + } + }, [applyJobTerminalState, t, toast]); + // ── Rebuild job output streaming ────────────────────────────────────────── useEffect(() => { if (!job || job.status !== "running") return; @@ -231,29 +380,25 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr end: (event) => { try { const snapshot = JSON.parse((event as MessageEvent).data) as SystemRebuildJobSnapshot; - setJob(snapshot); - if (snapshot.status === "succeeded" && snapshot.restartScheduled) { - prevPidRef.current = info?.pid ?? null; - setRestartPhase("waiting"); - } else if (snapshot.status === "succeeded") { - const successMsg = - snapshot.kind === "fn-binary" && snapshot.scope === "link-local" - ? t("systemControls.fnLinkLocalSucceeded", "Local fn binary built and linked") - : snapshot.kind === "fn-binary" && snapshot.scope === "use-global" - ? t("systemControls.fnUseGlobalSucceeded", "Switched default fn to global npm install") - : t("systemControls.rebuildSucceeded", "Rebuild finished successfully"); - toast(successMsg, "success"); - } else { - toast(t("systemControls.rebuildFailed", "Job failed — see output for details"), "error"); - } + applyJobTerminalState(snapshot); } catch { // Ignore malformed stream payloads. } }, }, + // See the FNXC:SystemPanelJobStreamResync note above: the stream replays lines and a terminal + // `end` only while the ORIGINAL server process is alive, so this subscription is not + // replaySafe. onReconnect covers a surviving server; onError covers the 404 that a + // restart-after rebuild leaves behind (no `open`, therefore no onReconnect). + onReconnect: () => { + void reconcileJobAfterStreamGap(); + }, + onError: () => { + void reconcileJobAfterStreamGap(); + }, }); return unsubscribe; - }, [job?.id, job?.status, info?.pid, t, toast]); + }, [job?.id, job?.status, applyJobTerminalState, reconcileJobAfterStreamGap]); useEffect(() => { const output = jobOutputRef.current; diff --git a/packages/dashboard/app/components/command-center/areas/__tests__/system-controls-job-stream.test.tsx b/packages/dashboard/app/components/command-center/areas/__tests__/system-controls-job-stream.test.tsx new file mode 100644 index 0000000000..cc3122e71b --- /dev/null +++ b/packages/dashboard/app/components/command-center/areas/__tests__/system-controls-job-stream.test.tsx @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { SystemControlsArea } from "../SystemControlsArea"; +import { __sseBusResyncAudit, SSE_HIDDEN_SUSPEND_DELAY_MS } from "../../../../sse-bus"; +import { + fetchCurrentSystemRebuild, + fetchSystemInfo, + type SystemInfoResponse, + type SystemRebuildJobSnapshot, +} from "../../../../api/legacy"; + +/* +FNXC:SystemPanelJobStreamResync 2026-07-26-16:40: +Regression coverage for the System panel's rebuild-job stream across the 60s hidden-tab suspend. + +Verified server contract (packages/dashboard/src/routes/register-system-routes.ts, +GET /system/jobs/:id/stream): the route replays every buffered line from `Last-Event-ID + 1` (0 for +the fresh EventSource the bus creates on resume) and, when `job.status !== "running"`, writes a +terminal `end` snapshot and closes. That makes the stream replay-safe ONLY while the job's server +process is alive — `jobsById` is process-local in-memory state, so a "Rebuild & restart" job that +finishes and bounces the server answers 404 on reconnect and never delivers `end`. + +The `end` handler is the sole writer of the terminal snapshot and of restartPhase="waiting", so +without a resync the panel shows "Running…" forever. Both surfaces are covered here: + 1. server survived → replay + `end` reaches the terminal state, and the reconnect resync must not + double-apply it (one toast, not two); + 2. server restarted → the reconnect is a 404 that never fires `open` (therefore never fires + `onReconnect`), so only `onError` can recover; the panel must leave "Running…" and reach the + back-online reload path. +This test uses the REAL sse-bus with a fake EventSource so the suspend/resume machinery under test is +the shipped one, not a stand-in. +*/ + +vi.mock("../../../../api/legacy", () => ({ + fetchSystemInfo: vi.fn(), + fetchCurrentSystemRebuild: vi.fn(), + fetchDashboardHealth: vi.fn().mockResolvedValue({ ok: true }), + fetchSystemLogs: vi.fn().mockResolvedValue({ entries: [] }), + createBackup: vi.fn(), + refreshUpdateCheck: vi.fn(), + reloadAllSystemPlugins: vi.fn(), + requestSystemRestart: vi.fn(), + restartAllSystemAgents: vi.fn(), + restartSystemEngines: vi.fn(), + startFnBinaryLinkLocal: vi.fn(), + startFnBinaryUseGlobal: vi.fn(), + startSystemRebuild: vi.fn(), +})); + +vi.mock("../../../ReportActionMenu", () => ({ ReportActionMenu: () => null })); +vi.mock("../../../ReportModal", () => ({ ReportModal: () => null })); + +const fetchSystemInfoMock = vi.mocked(fetchSystemInfo); +const fetchCurrentSystemRebuildMock = vi.mocked(fetchCurrentSystemRebuild); + +// ── Fake EventSource ──────────────────────────────────────────────────────── +type Listener = (event: Event) => void; + +class FakeEventSource { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSED = 2; + static instances: FakeEventSource[] = []; + + readyState = FakeEventSource.CONNECTING; + private readonly listeners = new Map>(); + + constructor(public readonly url: string) { + FakeEventSource.instances.push(this); + } + + addEventListener(type: string, listener: Listener): void { + let set = this.listeners.get(type); + if (!set) { + set = new Set(); + this.listeners.set(type, set); + } + set.add(listener); + } + + removeEventListener(type: string, listener: Listener): void { + this.listeners.get(type)?.delete(listener); + } + + close(): void { + this.readyState = FakeEventSource.CLOSED; + } + + /** Server accepted the connection. */ + emitOpen(): void { + this.readyState = FakeEventSource.OPEN; + this.dispatch("open"); + } + + /** Server refused it (e.g. 404 after the process restarted) — no `open` ever fires. */ + emitError(): void { + this.readyState = FakeEventSource.CLOSED; + this.dispatch("error"); + } + + emitNamed(type: string, data: unknown): void { + this.dispatch(type, JSON.stringify(data)); + } + + private dispatch(type: string, data?: string): void { + const event = data === undefined ? new Event(type) : Object.assign(new Event(type), { data }); + for (const listener of Array.from(this.listeners.get(type) ?? [])) listener(event); + } +} + +function jobStreamSources(jobId: string): FakeEventSource[] { + return FakeEventSource.instances.filter((es) => es.url.includes(`/api/system/jobs/${jobId}/stream`)); +} + +function latestJobStream(jobId: string): FakeEventSource { + const all = jobStreamSources(jobId); + const last = all[all.length - 1]; + if (!last) throw new Error(`no EventSource opened for job ${jobId}`); + return last; +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── +function info(pid: number): SystemInfoResponse { + return { + supervised: true, + restartSupported: true, + rebuildSupported: true, + fnBinaryLinkLocalSupported: true, + fnBinaryUseGlobalSupported: true, + sourceWorkspaceRoot: "/repo", + logsSupported: true, + engineAvailable: true, + pluginReloadSupported: true, + pid, + uptimeSeconds: 42, + nodeVersion: "v22.0.0", + platform: "darwin", + arch: "arm64", + memoryRssBytes: 1024, + activeRebuild: null, + lastRebuild: null, + }; +} + +const JOB_ID = "job-rebuild-1"; + +function runningJob(): SystemRebuildJobSnapshot { + return { + id: JOB_ID, + kind: "rebuild", + scope: "app", + restartAfter: true, + status: "running", + startedAt: 1_000, + droppedLines: 0, + lineCount: 1, + lines: [{ i: 0, ts: 1_000, stream: "system", text: "Building…" }], + }; +} + +function succeededJob(restartScheduled: boolean): SystemRebuildJobSnapshot { + return { + ...runningJob(), + restartAfter: restartScheduled, + status: "succeeded", + finishedAt: 2_000, + exitCode: 0, + restartScheduled, + lineCount: 2, + lines: [ + { i: 0, ts: 1_000, stream: "system", text: "Building…" }, + { i: 1, ts: 2_000, stream: "system", text: "Build succeeded." }, + ], + }; +} + +// ── Visibility control ────────────────────────────────────────────────────── +let visibility: DocumentVisibilityState = "visible"; + +function setVisibility(next: DocumentVisibilityState): void { + visibility = next; + document.dispatchEvent(new Event("visibilitychange")); +} + +const originalLocation = window.location; +const originalEventSource = (globalThis as { EventSource?: unknown }).EventSource; +let reloadSpy: ReturnType; +let toasts: Array<{ message: string; type?: string }>; + +function addToast(message: string, type?: string): void { + toasts.push({ message, type }); +} + +async function flush(ms = 0): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +/** Render with a running job already in flight and its stream connected. */ +async function renderWithRunningJob(): Promise { + fetchSystemInfoMock.mockResolvedValue(info(1000)); + fetchCurrentSystemRebuildMock.mockResolvedValue({ job: runningJob() }); + render( void} />); + await flush(); + expect(screen.getByText("Running…")).toBeInTheDocument(); + latestJobStream(JOB_ID).emitOpen(); + await flush(); +} + +/** Hide the tab past the suspend threshold, then bring it back. */ +async function hideBeyondSuspendThenReturn(): Promise { + setVisibility("hidden"); + await flush(SSE_HIDDEN_SUSPEND_DELAY_MS + 1_000); + const beforeResume = jobStreamSources(JOB_ID).length; + setVisibility("visible"); + await flush(); + // The bus must have opened a fresh transport for the resumed channel. + expect(jobStreamSources(JOB_ID).length).toBeGreaterThan(beforeResume); +} + +describe("SystemControlsArea rebuild-job stream across the hidden-tab suspend", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + FakeEventSource.instances = []; + toasts = []; + visibility = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, + }); + (globalThis as { EventSource?: unknown }).EventSource = FakeEventSource; + // jsdom has no layout: the panel scrolls its job section into view on start. + Element.prototype.scrollIntoView = vi.fn(); + reloadSpy = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...originalLocation, origin: originalLocation.origin, href: originalLocation.href, reload: reloadSpy }, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + (globalThis as { EventSource?: unknown }).EventSource = originalEventSource; + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); + + it("declares a resync path for the job stream (sse-bus contract audit)", async () => { + await renderWithRunningJob(); + expect(Object.keys(__sseBusResyncAudit())).not.toContain(`/api/system/jobs/${JOB_ID}/stream`); + }); + + it("reaches the terminal state from the replayed end event when the server survived, applying it once", async () => { + await renderWithRunningJob(); + + // Job completes while the tab is hidden; server process survives (no restart scheduled). + const terminal = succeededJob(false); + fetchCurrentSystemRebuildMock.mockResolvedValue({ job: terminal }); + + await hideBeyondSuspendThenReturn(); + + // Real server behavior on reconnect: replay the buffered lines, then the terminal `end`. + const stream = latestJobStream(JOB_ID); + stream.emitOpen(); + for (const line of terminal.lines ?? []) stream.emitNamed("line", line); + stream.emitNamed("end", { ...terminal, lines: undefined }); + await flush(); + // Let the staggered onReconnect resync (which raced the `end`) settle too. + await flush(5_000); + + expect(screen.queryByText("Running…")).toBeNull(); + expect(screen.getByText("Succeeded")).toBeInTheDocument(); + expect(toasts.filter((entry) => entry.message === "Rebuild finished successfully")).toHaveLength(1); + }); + + it("recovers when the rebuild restarted the server while hidden and the stream 404s (no end event)", async () => { + await renderWithRunningJob(); + + // The job finished and restarted the server. The new process has no record of the job, and its + // PID differs. This is the case the stream cannot replay. + fetchCurrentSystemRebuildMock.mockResolvedValue({ job: null }); + fetchSystemInfoMock.mockResolvedValue(info(2000)); + + await hideBeyondSuspendThenReturn(); + + // 404 → EventSource errors without ever firing `open`, so `onReconnect` never runs. + latestJobStream(JOB_ID).emitError(); + await flush(); + + expect(screen.queryByText("Running…")).toBeNull(); + expect(screen.getByText("Server is back online — reloading…")).toBeInTheDocument(); + + await flush(5_000); + expect(reloadSpy).toHaveBeenCalled(); + }); + + it("reports an unknown outcome rather than a permanent Running… when the job vanished without a restart", async () => { + await renderWithRunningJob(); + + // Same process (same PID), job no longer known: we cannot prove success or failure. + fetchCurrentSystemRebuildMock.mockResolvedValue({ job: null }); + fetchSystemInfoMock.mockResolvedValue(info(1000)); + + await hideBeyondSuspendThenReturn(); + latestJobStream(JOB_ID).emitError(); + await flush(); + + expect(screen.queryByText("Running…")).toBeNull(); + expect(screen.getByText("Failed")).toBeInTheDocument(); + expect(toasts.map((entry) => entry.message)).toContain( + "Lost the job stream before a result arrived — the outcome is unknown", + ); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/resyncRetry.test.ts b/packages/dashboard/app/hooks/__tests__/resyncRetry.test.ts new file mode 100644 index 0000000000..1f21a08401 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/resyncRetry.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createResyncRetryRunner, DEFAULT_RESYNC_RETRY_DELAYS_MS } from "../resyncRetry"; + +/* +FNXC:SseResync 2026-07-26-19:40: +The invariant under test is "a failed reconnect resync retries a BOUNDED number of times and then +reports degradation" — the property every resync handler relies on. The previous behavior (catch and +do nothing, waiting for a reconnect that may never come) is asserted against explicitly. +*/ +describe("createResyncRetryRunner", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("runs once when the attempt succeeds", async () => { + const run = vi.fn().mockResolvedValue(undefined); + const runner = createResyncRetryRunner({ run, delaysMs: [10, 20] }); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(100); + + expect(run).toHaveBeenCalledTimes(1); + }); + + it("retries a failed attempt on the configured ladder and stops when it succeeds", async () => { + const run = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(undefined); + const runner = createResyncRetryRunner({ run, delaysMs: [10, 20] }); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(10); + expect(run).toHaveBeenCalledTimes(2); + + // Second attempt succeeded: the ladder stops, it does not keep retrying. + await vi.advanceTimersByTimeAsync(1000); + expect(run).toHaveBeenCalledTimes(2); + }); + + it("stops after the ladder is exhausted and reports degradation exactly once", async () => { + const run = vi.fn().mockRejectedValue(new Error("offline")); + const onExhausted = vi.fn(); + const runner = createResyncRetryRunner({ run, delaysMs: [10, 20], onExhausted }); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(1000); + + // 1 initial attempt + 2 retries, then STOP — bounded, never an unbounded hammer. + expect(run).toHaveBeenCalledTimes(3); + expect(onExhausted).toHaveBeenCalledTimes(1); + }); + + it("clears degradation on the first success after an exhausted ladder", async () => { + const run = vi.fn().mockRejectedValue(new Error("offline")); + const onExhausted = vi.fn(); + const onRecovered = vi.fn(); + const runner = createResyncRetryRunner({ run, delaysMs: [10], onExhausted, onRecovered }); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(1000); + expect(onExhausted).toHaveBeenCalledTimes(1); + expect(onRecovered).not.toHaveBeenCalled(); + + run.mockResolvedValue(undefined); + runner.trigger(); + await vi.advanceTimersByTimeAsync(0); + + expect(onRecovered).toHaveBeenCalledTimes(1); + }); + + it("a new trigger supersedes a pending retry instead of stacking attempts", async () => { + const run = vi.fn().mockRejectedValue(new Error("offline")); + const runner = createResyncRetryRunner({ run, delaysMs: [100] }); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(2); + + // The superseded retry timer must not fire a third attempt. + await vi.advanceTimersByTimeAsync(100); + expect(run).toHaveBeenCalledTimes(3); + await vi.advanceTimersByTimeAsync(1000); + expect(run).toHaveBeenCalledTimes(3); + }); + + it("does not overlap attempts while one is in flight", async () => { + let resolveRun: (() => void) | undefined; + const run = vi.fn(() => new Promise((resolve) => { resolveRun = () => resolve(); })); + const runner = createResyncRetryRunner({ run, delaysMs: [10] }); + + runner.trigger(); + runner.trigger(); + runner.trigger(); + await vi.advanceTimersByTimeAsync(0); + + expect(run).toHaveBeenCalledTimes(1); + resolveRun?.(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("dispose cancels pending retries and suppresses callbacks", async () => { + const run = vi.fn().mockRejectedValue(new Error("offline")); + const onExhausted = vi.fn(); + const runner = createResyncRetryRunner({ run, delaysMs: [10], onExhausted }); + + runner.trigger(); + await vi.advanceTimersByTimeAsync(0); + runner.dispose(); + + await vi.advanceTimersByTimeAsync(1000); + expect(run).toHaveBeenCalledTimes(1); + expect(onExhausted).not.toHaveBeenCalled(); + }); + + it("defaults to a two-retry ladder", () => { + expect(DEFAULT_RESYNC_RETRY_DELAYS_MS).toHaveLength(2); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts index 76564b1475..355615ed85 100644 --- a/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts +++ b/packages/dashboard/app/hooks/__tests__/sseSplitIntegration.test.ts @@ -69,10 +69,19 @@ describe("SSE split (KTD4): mailbox-refresh vs approval-banner", () => { const approvalSub = subscriptions.find((s) => "task:updated" in s.events); expect(mailboxSub).toBeTruthy(); expect(approvalSub).toBeTruthy(); - // The split extends to reconnect handling: the mailbox subscription wires - // an onReconnect (re-fetch counts), the approval banner does not. + /* + FNXC:SSE-Split-Reconnect 2026-07-26-18:05: + The split extends to reconnect handling, but NOT as "only the mailbox resyncs". Per the sse-bus + contract every subscriber must declare a resync path: the mailbox subscription re-fetches counts, + and the approval banner re-reads the authoritative pending list. This test previously asserted the + banner had NO onReconnect, which encoded the exact defect the mobile hidden-tab suspend work fixed — + an approval raised while the tab was backgrounded stayed invisible and its agent blocked forever. + What the split still guarantees is that the two resyncs are SEPARATE functions owned by their own + hooks, not one shared handler. + */ expect(mailboxSub!.onReconnect).toBeTruthy(); - expect(approvalSub!.onReconnect).toBeUndefined(); + expect(approvalSub!.onReconnect).toBeTruthy(); + expect(mailboxSub!.onReconnect).not.toBe(approvalSub!.onReconnect); // (i) approval:requested sets the banner candidate; the mailbox hook's // approval:requested handler (count refresh) remains a distinct diff --git a/packages/dashboard/app/hooks/__tests__/useActivityLog.test.ts b/packages/dashboard/app/hooks/__tests__/useActivityLog.test.ts index 5029ee5f51..9e272d3695 100644 --- a/packages/dashboard/app/hooks/__tests__/useActivityLog.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useActivityLog.test.ts @@ -269,6 +269,84 @@ describe("useActivityLog", () => { }); }); + // ── Retention cap vs. pagination ────────────────────────────────── + + /* + FNXC:ActivityLogPaging 2026-07-26-18:45: + Regression coverage for a silent pagination stop. With the 500-entry retention cap applied as + `merged.slice(0, MAX)`, the eleventh "Load more" (limit 50) fetched a page and then threw away exactly + that page, while `lastTimestampRef` still advanced past it and `hasMore` stayed true. The feed stopped + moving, the button kept promising it would, and the skipped entries became unreachable because the + cursor had passed them. + + The invariant asserted here is the user-visible one: every click of an offered "Load more" must put + entries on screen that were not there before, at the cap as well as below it. + */ + function paginatedLogPage(page: number, size: number) { + return Array.from({ length: size }, (_, i) => ({ + id: `p${page}_${i}`, + timestamp: new Date(Date.UTC(2024, 0, 1) - (page * size + i) * 60_000).toISOString(), + type: "task:created" as const, + taskId: "FN-001", + taskTitle: "Test Task", + details: "Task created", + })); + } + + it("keeps the page it just fetched when loadMore crosses the retention cap", async () => { + const PAGE = 50; + let page = 0; + mockFetchActivityLog.mockImplementation(async () => paginatedLogPage(page++, PAGE)); + + const { result } = renderHook(() => useActivityLog({ limit: PAGE, autoRefresh: false })); + await waitFor(() => expect(result.current.entries).toHaveLength(PAGE)); + + // Nine clicks fill the buffer exactly to the 500-entry cap. + for (let click = 0; click < 9; click++) { + await act(async () => { + await result.current.loadMore(); + }); + } + expect(result.current.entries).toHaveLength(500); + expect(result.current.hasMore).toBe(true); + + const idsAtCap = result.current.entries.map((entry) => entry.id); + + // The tenth click is the one that used to be a no-op. + await act(async () => { + await result.current.loadMore(); + }); + + const idsAfter = result.current.entries.map((entry) => entry.id); + expect(idsAfter).toHaveLength(500); + expect(idsAfter).not.toEqual(idsAtCap); + // The whole freshly fetched (older) page is present… + expect(idsAfter).toContain("p10_0"); + expect(idsAfter).toContain("p10_49"); + // …paid for from the head, which `refresh` can fetch again from offset 0. + expect(idsAfter).not.toContain("p0_0"); + expect(mockFetchActivityLog).toHaveBeenCalledTimes(11); + }); + + it("continues to page backwards across several clicks past the cap", async () => { + const PAGE = 50; + let page = 0; + mockFetchActivityLog.mockImplementation(async () => paginatedLogPage(page++, PAGE)); + + const { result } = renderHook(() => useActivityLog({ limit: PAGE, autoRefresh: false })); + await waitFor(() => expect(result.current.entries).toHaveLength(PAGE)); + + for (let click = 0; click < 12; click++) { + await act(async () => { + await result.current.loadMore(); + }); + } + + // Twelve clicks past a 50-entry first page = pages 0..12; the oldest page must be on screen. + expect(result.current.entries.map((entry) => entry.id)).toContain("p12_49"); + expect(result.current.entries).toHaveLength(500); + }); + it("passes type filter to unified feed when useCentralFeed is true", async () => { mockFetchActivityFeed.mockResolvedValue([]); diff --git a/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts b/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts index 828b461cec..c0ae3c9992 100644 --- a/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts @@ -629,6 +629,16 @@ describe("useAgentLogs", () => { expect(result.current.entries.some(isLogGapMarker)).toBe(false); }); + /* + FNXC:AgentLogResync 2026-07-26-19:40: + This case previously asserted that the pre-suspend entries were ABSENT after a no-overlap resync + ("the unreconcilable prefix is not silently glued onto a page it does not touch"). The + de-duplication half of that was right; the discarding half was the defect: a reader who paged + back five times and then backgrounded the tab lost ~600 explicitly fetched entries and their + scroll position, replaced by the newest 100. The entries are now RETAINED below the marker, which + is what makes the discontinuity visible in place instead of implied by absence. The + no-blind-concatenation invariant is still asserted — the marker sits between the two windows. + */ it("renders an explicit gap marker when the missed window exceeds one authoritative page", async () => { mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [logEntry(0, "before-suspend")], @@ -663,16 +673,18 @@ describe("useAgentLogs", () => { }); await waitFor(() => { - expect(result.current.entries).toHaveLength(INITIAL_LOAD_LIMIT + 1); + expect(result.current.entries).toHaveLength(INITIAL_LOAD_LIMIT + 2); }); - expect(isLogGapMarker(result.current.entries[0])).toBe(true); - expect(result.current.entries[0].text.length).toBeGreaterThan(0); - expect(result.current.entries.slice(1).map((entry) => entry.text)).toEqual( + // History kept, discontinuity marked in place, refetched page below it. + expect(result.current.entries[0].text).toBe("before-suspend"); + expect(isLogGapMarker(result.current.entries[1])).toBe(true); + expect(result.current.entries[1].text.length).toBeGreaterThan(0); + expect(result.current.entries.slice(2).map((entry) => entry.text)).toEqual( authoritativePage.map((entry) => entry.text), ); - // The unreconcilable prefix is not silently glued onto a page it does not touch. - expect(result.current.entries.some((entry) => entry.text === "before-suspend")).toBe(false); + // The two windows are never glued together as if they touched. + expect(result.current.entries.filter(isLogGapMarker)).toHaveLength(1); expect(result.current.hasMore).toBe(true); }); @@ -746,6 +758,354 @@ describe("useAgentLogs", () => { }); }); + /* + FNXC:AgentLogPaging 2026-07-26-19:48: + P2: nothing used to arbitrate loadMore against a reconnect resync. loadMore captures its offset + from the rendered buffer; a resync landing while that fetch is in flight replaces the buffer, and + prepending the offset page onto the replacement produced `[entries 500-600][newest 100]` — a hole + with no marker, and every later offset wrong. The stale page must be DISCARDED, not spliced. + */ + it("discards a loadMore page whose buffer was replaced by a reconnect resync", async () => { + const initialPage = Array.from({ length: 5 }, (_, index) => logEntry(index, `hist-${index}`)); + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: initialPage, total: 400, hasMore: true }); + + const { result } = renderHook(() => useAgentLogs("FN-001", true)); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(5); + }); + + const es = MockEventSource.instances[0]; + act(() => { + es._fire("open"); + }); + + // The "load older" page is still on the radio when the socket reopens. + const olderPage = createDeferred<{ entries: any[]; total: number; hasMore: boolean }>(); + mockFetchAgentLogsWithMeta.mockReturnValueOnce(olderPage.promise); + + let loadMorePromise: Promise | undefined; + act(() => { + loadMorePromise = result.current.loadMore(); + }); + + // Reconnect resync replaces the buffer (no overlap with the held window). + const authoritativePage = Array.from({ length: 3 }, (_, index) => logEntry(index + 30, `fresh-${index}`)); + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ + entries: authoritativePage, + total: 403, + hasMore: true, + }); + + await act(async () => { + es._fire("open"); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.entries.some((entry) => entry.text === "fresh-0")).toBe(true); + }); + const afterResync = result.current.entries.map((entry) => entry.text); + + await act(async () => { + olderPage.resolve({ + entries: Array.from({ length: 4 }, (_, index) => logEntry(index, `stale-older-${index}`)), + total: 400, + hasMore: true, + }); + await loadMorePromise; + }); + + expect(result.current.entries.map((entry) => entry.text)).toEqual(afterResync); + expect(result.current.entries.some((entry) => entry.text.startsWith("stale-older-"))).toBe(false); + // The affordance stays reachable so the reader can page again from the corrected offset. + expect(result.current.hasMore).toBe(true); + }); + + /* + FNXC:AgentLogPaging 2026-07-26-19:56: + P2: a no-overlap resync used to replace explicitly paged-back history with the newest page. + History is now retained below the gap marker, and each subsequent "load older" pages from the + NEWEST contiguous block (offset = entries below the marker) so the gap fills from the bottom + until it overlaps the retained history and the marker retires. + */ + it("retains paged-back history below the gap marker and fills the gap from below", async () => { + const page1 = Array.from({ length: 100 }, (_, index) => logEntry(index + 100, `mid-${index}`)); + const page0 = Array.from({ length: 100 }, (_, index) => logEntry(index, `old-${index}`)); + mockFetchAgentLogsWithMeta + .mockResolvedValueOnce({ entries: page1, total: 1000, hasMore: true }) + .mockResolvedValueOnce({ entries: page0, total: 1000, hasMore: true }); + + const { result } = renderHook(() => useAgentLogs("FN-001", true)); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(100); + }); + await act(async () => { + await result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.entries).toHaveLength(200); + }); + + const es = MockEventSource.instances[0]; + act(() => { + es._fire("open"); + }); + + const freshPage = Array.from({ length: 100 }, (_, index) => logEntry(index + 500, `fresh-${index}`)); + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: freshPage, total: 1000, hasMore: true }); + + await act(async () => { + es._fire("open"); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(301); + }); + expect(result.current.entries[0].text).toBe("old-0"); + expect(isLogGapMarker(result.current.entries[200])).toBe(true); + expect(result.current.entries[201].text).toBe("fresh-0"); + + // Gap-fill page: overlaps the retained history by 10 entries, so the marker is proven closed. + const gapFill = [ + ...page1.slice(90), + ...Array.from({ length: 90 }, (_, index) => logEntry(index + 300, `gap-${index}`)), + ]; + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: gapFill, total: 1000, hasMore: true }); + + await act(async () => { + await result.current.loadMore(); + }); + + // Offset is the newest contiguous block (the refetched page), not every real entry held. + expect(mockFetchAgentLogsWithMeta).toHaveBeenLastCalledWith("FN-001", undefined, { + limit: INITIAL_LOAD_LIMIT, + offset: 100, + }); + await waitFor(() => { + expect(result.current.entries.some(isLogGapMarker)).toBe(false); + }); + const texts = result.current.entries.map((entry) => entry.text); + expect(new Set(texts).size).toBe(texts.length); + expect(texts.slice(0, 3)).toEqual(["old-0", "old-1", "old-2"]); + expect(texts.slice(-1)).toEqual(["fresh-99"]); + expect(texts).toContain("gap-0"); + }); + + /* + FNXC:AgentLogPaging 2026-07-26-20:04: + The server resolves `offset` against its CURRENT total, so entries persisted between the client + reading its offset and the server reading the log shift the returned window newer. Blind + concatenation duplicated the overlapping seam; the merge de-duplicates it. + */ + it("does not duplicate the seam when the log grew between the offset read and the server read", async () => { + const held = [logEntry(10, "held-a"), logEntry(11, "held-b")]; + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: held, total: 20, hasMore: true }); + + const { result } = renderHook(() => useAgentLogs("FN-001", true)); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(2); + }); + + // Shifted page: its tail is the buffer's head. + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ + entries: [logEntry(8, "older-a"), logEntry(9, "older-b"), logEntry(10, "held-a")], + total: 21, + hasMore: true, + }); + + await act(async () => { + await result.current.loadMore(); + }); + + expect(result.current.entries.map((entry) => entry.text)).toEqual([ + "older-a", + "older-b", + "held-a", + "held-b", + ]); + }); + + /* + FNXC:AgentLogResync 2026-07-26-20:12: + P2: forceReconnect fires onReconnect at teardown and again ~3s later on the successful open. A + first resync that outlived that delay swallowed the second reconnect at the single-flight early + return, so the lines emitted between the first fetch's snapshot and the socket reopening were + never fetched and never streamed — and the merge left NO gap marker, rendering a real hole as + contiguous output. A superseded reconnect must re-run the resync once the in-flight one settles. + */ + it("re-runs the resync when a reconnect arrives while one is in flight", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ + entries: [logEntry(0, "hist-1")], + total: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useAgentLogs("FN-001", true)); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(1); + }); + + const es = MockEventSource.instances[0]; + act(() => { + es._fire("open"); + }); + + const slowResync = createDeferred<{ entries: any[]; total: number; hasMore: boolean }>(); + mockFetchAgentLogsWithMeta.mockReturnValueOnce(slowResync.promise); + + act(() => { + es._fire("open"); + }); + const callsAfterFirstReconnect = mockFetchAgentLogsWithMeta.mock.calls.length; + + // Second reconnect while the first refetch is still on the radio. + act(() => { + es._fire("open"); + }); + expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBe(callsAfterFirstReconnect); + + // The re-run's page is the only source of the lines emitted during the second outage. + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ + entries: [logEntry(0, "hist-1"), logEntry(1, "during-outage")], + total: 2, + hasMore: false, + }); + + await act(async () => { + slowResync.resolve({ entries: [logEntry(0, "hist-1")], total: 1, hasMore: false }); + await slowResync.promise; + }); + + await waitFor(() => { + expect(result.current.entries.map((entry) => entry.text)).toEqual(["hist-1", "during-outage"]); + }); + expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBe(callsAfterFirstReconnect + 1); + }); + + /* + FNXC:AgentLogResync 2026-07-26-20:22: + P3: live events parked during an in-flight resync had no ceiling, so a verbose agent on a waking + radio rebuilt exactly the unbounded array MAX_LOG_ENTRIES exists to eliminate. At the cap the + resync is abandoned and the parked batch is flushed into the ring: bounded memory, no dropped + lines beyond the ring's own newest-wins trim, and a fresh resync once the abandoned fetch settles. + */ + it("bounds live events parked during a resync and flushes them into the ring", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false }); + + const { result } = renderHook(() => useAgentLogs("FN-001", true)); + + await waitFor(() => { + expect(MockEventSource.instances).toHaveLength(1); + }); + + const es = MockEventSource.instances[0]; + act(() => { + es._fire("open"); + }); + + const stuckResync = createDeferred<{ entries: any[]; total: number; hasMore: boolean }>(); + mockFetchAgentLogsWithMeta.mockReturnValueOnce(stuckResync.promise); + act(() => { + es._fire("open"); + }); + + const streamed = MAX_LOG_ENTRIES + 40; + act(() => { + for (let index = 0; index < streamed; index++) { + es._emit("agent:log", { + timestamp: `2026-01-01T${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}:00Z`, + taskId: "FN-001", + text: `parked-${index}`, + type: "text", + }); + } + }); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES); + }); + expect(result.current.entries.at(-1)?.text).toBe(`parked-${streamed - 1}`); + expect(result.current.hasMore).toBe(true); + + // The abandoned resync's result is discarded, and a fresh one runs once it settles. + const callsBeforeSettle = mockFetchAgentLogsWithMeta.mock.calls.length; + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: streamed, hasMore: true }); + await act(async () => { + stuckResync.resolve({ entries: [logEntry(0, "stale-snapshot")], total: 1, hasMore: false }); + await stuckResync.promise; + }); + + expect(result.current.entries.some((entry) => entry.text === "stale-snapshot")).toBe(false); + await waitFor(() => { + expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBe(callsBeforeSettle + 1); + }); + }); + + /* + FNXC:AgentLogResync 2026-07-26-20:30: + P3: the live-tail trim must never evict the gap marker. Once the buffer is at the ring ceiling, + each streamed line drops the oldest entry; when the entry above the marker is gone the marker + becomes the head and MUST survive every later trim, or the rendered log loses the only signal + that output is missing above while the output stays missing. + */ + it("never trims away the gap marker while streaming at the ring ceiling", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ + entries: [logEntry(0, "before-suspend")], + total: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useAgentLogs("FN-001", true)); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(1); + }); + + const es = MockEventSource.instances[0]; + act(() => { + es._fire("open"); + }); + + const authoritativePage = Array.from({ length: 100 }, (_, index) => logEntry(index + 5, `fresh-${index}`)); + mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ + entries: authoritativePage, + total: 500, + hasMore: true, + }); + await act(async () => { + es._fire("open"); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.entries.some(isLogGapMarker)).toBe(true); + }); + + act(() => { + for (let index = 0; index < MAX_LOG_ENTRIES; index++) { + es._emit("agent:log", { + timestamp: `2026-02-01T${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}:00Z`, + taskId: "FN-001", + text: `after-${index}`, + type: "text", + }); + } + }); + + await waitFor(() => { + expect(result.current.entries).toHaveLength(MAX_LOG_ENTRIES); + }); + expect(isLogGapMarker(result.current.entries[0])).toBe(true); + expect(result.current.entries.filter(isLogGapMarker)).toHaveLength(1); + expect(result.current.entries.at(-1)?.text).toBe(`after-${MAX_LOG_ENTRIES - 1}`); + expect(result.current.hasMore).toBe(true); + }); + it("keeps streaming into the buffer when the reconnect refetch fails", async () => { mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [logEntry(0, "hist-1")], diff --git a/packages/dashboard/app/hooks/__tests__/useApprovalBanner.resync.test.ts b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.resync.test.ts new file mode 100644 index 0000000000..12da4228c3 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useApprovalBanner.resync.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +/* +FNXC:ApprovalBanner 2026-07-26-19:50: +Reconnect-resync coverage. The failure this guards is the worst in the mobile-retention change set: +an approval raised while the tab was suspended never renders its banner, so an agent blocks +indefinitely on a decision the operator was never shown. A single refetch on reopen is NOT enough — +if that one attempt fails there may be no further reopen for hours — so the invariant is +"a failed resync retries on a bounded ladder, and if the ladder is exhausted the hook reports that +the banner state may be incomplete". +*/ + +const { sseOptions } = vi.hoisted(() => ({ + sseOptions: { current: null as null | { onReconnect?: () => void } }, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { onReconnect?: () => void }) => { + sseOptions.current = opts; + return () => {}; + }), +})); + +vi.mock("../../api", () => ({ + fetchApprovals: vi.fn(), +})); + +import { useApprovalBanner } from "../useApprovalBanner"; +import * as apiModule from "../../api"; + +const mockFetchApprovals = vi.mocked(apiModule.fetchApprovals); + +// Stable identities: the hook's SSE effect keys on them, so a fresh callback per render would +// resubscribe (and drop the retry ladder) on every state change — an artifact, not the behavior +// under test. +const STABLE_TASKS: never[] = []; +const stableOnStarPrompt = vi.fn(); + +function renderApprovalBannerHook() { + return renderHook(() => + useApprovalBanner({ + tasks: STABLE_TASKS, + currentProjectId: "p1", + gitHubStarPromptShown: true, + onStarPrompt: stableOnStarPrompt, + }), + ); +} + +function pendingRequest(id: string, updatedAt: string) { + return { id, updatedAt, createdAt: updatedAt, status: "pending" }; +} + +describe("useApprovalBanner reconnect resync", () => { + beforeEach(() => { + vi.useFakeTimers(); + sseOptions.current = null; + window.localStorage.clear(); + mockFetchApprovals.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("surfaces an approval raised while the stream was suspended", async () => { + mockFetchApprovals.mockResolvedValue({ + requests: [pendingRequest("a9", "2026-01-01T00:00:00Z")], + } as never); + + const { result } = renderApprovalBannerHook(); + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.candidate?.dedupeKey).toBe("approval:a9"); + expect(result.current.approvalsMayBeIncomplete).toBe(false); + }); + + it("retries a failed resync instead of waiting for a reconnect that may never come", async () => { + mockFetchApprovals + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue({ requests: [pendingRequest("a9", "2026-01-01T00:00:00Z")] } as never); + + const { result } = renderApprovalBannerHook(); + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await vi.advanceTimersByTimeAsync(0); + }); + // Only the failed attempt so far: nothing is on screen and the old code stopped here forever. + expect(mockFetchApprovals).toHaveBeenCalledTimes(1); + expect(result.current.candidate).toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + + expect(mockFetchApprovals).toHaveBeenCalledTimes(2); + expect(result.current.candidate?.dedupeKey).toBe("approval:a9"); + expect(result.current.approvalsMayBeIncomplete).toBe(false); + }); + + it("reports a possibly incomplete banner state when the whole ladder fails, and stops retrying", async () => { + mockFetchApprovals.mockRejectedValue(new Error("offline")); + + const { result } = renderApprovalBannerHook(); + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(result.current.approvalsMayBeIncomplete).toBe(true); + // Bounded: the congested resume edge is not hammered. + expect(mockFetchApprovals).toHaveBeenCalledTimes(3); + + // And it recovers (and clears the signal) on the next healthy reconnect. + mockFetchApprovals.mockResolvedValue({ + requests: [pendingRequest("a9", "2026-01-01T00:00:00Z")], + } as never); + await act(async () => { + sseOptions.current?.onReconnect?.(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.approvalsMayBeIncomplete).toBe(false); + expect(result.current.candidate?.dedupeKey).toBe("approval:a9"); + }); + + it("a failed resync never clears a banner already on screen", async () => { + mockFetchApprovals.mockRejectedValue(new Error("offline")); + + const { result } = renderApprovalBannerHook(); + + act(() => { + // The banner is already showing from a live approval:requested event. + (sseOptions.current as unknown as { events: Record void> }) + .events["approval:requested"]({ + data: JSON.stringify({ id: "a1", updatedAt: "2026-01-01T00:00:00Z" }), + } as MessageEvent); + }); + expect(result.current.candidate?.dedupeKey).toBe("approval:a1"); + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(result.current.candidate?.dedupeKey).toBe("approval:a1"); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useChat.resync.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.resync.test.ts new file mode 100644 index 0000000000..b5d886e1e4 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useChat.resync.test.ts @@ -0,0 +1,175 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatSession } from "@fusion/core"; + +/* +FNXC:ChatRealtime 2026-07-26-20:30: +Reconnect-resync coverage for chat. The latch under test: `streamRef` is cleared ONLY by the stream's +own onDone/onError, so a transport iOS killed during a background suspend without delivering either +leaves a dead stream marked live — and every later reconnect handler that guarded on it became a +permanent no-op (frozen transcript, reply never lands, no refetch ever attempted). The invariant is +that a reconnect reconciles the local stream against the server's generation state rather than +trusting it. +*/ + +vi.mock("../../api", () => ({ + fetchChatSessions: vi.fn(), + fetchChatTags: vi.fn().mockResolvedValue({ tags: [] }), + fetchChatSession: vi.fn(), + createChatSession: vi.fn(), + fetchChatMessages: vi.fn(), + updateChatSession: vi.fn(), + deleteChatSession: vi.fn(), + editChatMessage: vi.fn(), + streamChatResponse: vi.fn(), + attachChatStream: vi.fn(), + cancelChatResponse: vi.fn(), + fetchAgents: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../../utils/projectStorage", () => ({ + getScopedItem: vi.fn(), + setScopedItem: vi.fn(), + removeScopedItem: vi.fn(), +})); + +const { sseOptions } = vi.hoisted(() => ({ + sseOptions: { current: null as null | { onReconnect?: () => void } }, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: { onReconnect?: () => void }) => { + sseOptions.current = opts; + return () => {}; + }), +})); + +import { useChat } from "../useChat"; +import * as apiModule from "../../api"; + +const mockFetchChatSessions = vi.mocked(apiModule.fetchChatSessions); +const mockFetchChatSession = vi.mocked(apiModule.fetchChatSession); +const mockFetchChatMessages = vi.mocked(apiModule.fetchChatMessages); +const mockAttachChatStream = vi.mocked(apiModule.attachChatStream); + +function makeSession(overrides: Partial = {}): ChatSession { + return { + id: "session-001", + agentId: "agent-001", + status: "active", + title: null, + projectId: null, + modelProvider: null, + modelId: null, + thinkingLevel: null, + createdAt: "2026-04-08T00:00:00.000Z", + updatedAt: "2026-04-08T00:00:00.000Z", + pinnedAt: null, + cliSessionFile: null, + cliExecutorAdapterId: null, + inFlightGeneration: null, + ...overrides, + } as ChatSession; +} + +describe("useChat reconnect resync", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + sseOptions.current = null; + mockFetchChatMessages.mockResolvedValue({ messages: [] } as never); + }); + + /** Renders the hook with one generating session selected and a stream attached. */ + async function renderWithAttachedStream() { + const generating = { ...makeSession(), isGenerating: true, inFlightGeneration: null }; + mockFetchChatSessions.mockResolvedValue({ sessions: [generating] } as never); + mockFetchChatSession.mockResolvedValue({ session: generating } as never); + const close = vi.fn(); + mockAttachChatStream.mockReturnValue({ close, isConnected: () => true } as never); + + const rendered = renderHook(() => useChat(undefined)); + await waitFor(() => { + expect(rendered.result.current.sessions).toHaveLength(1); + }); + + act(() => { + rendered.result.current.selectSession("session-001"); + }); + await waitFor(() => { + expect(mockAttachChatStream).toHaveBeenCalled(); + }); + + return { ...rendered, close }; + } + + it("recovers when the attached stream died without a terminal callback", async () => { + const { result, close } = await renderWithAttachedStream(); + + // The server finished generating while the tab was suspended; the transport never delivered + // onDone/onError, so streamRef is still set. + mockFetchChatSession.mockResolvedValue({ + session: { ...makeSession(), isGenerating: false, inFlightGeneration: null }, + } as never); + const messageLoadsBefore = mockFetchChatMessages.mock.calls.length; + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await Promise.resolve(); + }); + + await waitFor(() => { + // The dead stream is torn down and the transcript is refetched — not latched off forever. + expect(close).toHaveBeenCalled(); + expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThan(messageLoadsBefore); + expect(result.current.isStreaming).toBe(false); + }); + }); + + it("keeps a stream the server confirms is still generating", async () => { + const { result, close } = await renderWithAttachedStream(); + + mockFetchChatSession.mockResolvedValue({ + session: { ...makeSession(), isGenerating: true, inFlightGeneration: null }, + } as never); + const messageLoadsBefore = mockFetchChatMessages.mock.calls.length; + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await Promise.resolve(); + }); + + expect(close).not.toHaveBeenCalled(); + // The streaming path still owns the transcript: no authoritative reload fights it. + expect(mockFetchChatMessages.mock.calls.length).toBe(messageLoadsBefore); + expect(result.current.isStreaming).toBe(true); + }); + + it("reloads the transcript on reconnect when no stream is attached", async () => { + const idle = makeSession(); + mockFetchChatSessions.mockResolvedValue({ sessions: [idle] } as never); + mockFetchChatSession.mockResolvedValue({ session: idle } as never); + + const { result } = renderHook(() => useChat(undefined)); + await waitFor(() => { + expect(result.current.sessions).toHaveLength(1); + }); + act(() => { + result.current.selectSession("session-001"); + }); + await waitFor(() => { + expect(result.current.activeSession?.id).toBe("session-001"); + }); + + const messageLoadsBefore = mockFetchChatMessages.mock.calls.length; + await act(async () => { + sseOptions.current?.onReconnect?.(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(mockFetchChatMessages.mock.calls.length).toBeGreaterThan(messageLoadsBefore); + }); + expect(mockAttachChatStream).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useChat.test.ts b/packages/dashboard/app/hooks/__tests__/useChat.test.ts index 1d9a50882c..d3ffac4ae1 100644 --- a/packages/dashboard/app/hooks/__tests__/useChat.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useChat.test.ts @@ -2224,7 +2224,16 @@ describe("useChat", () => { }); }); - it("fetches session on visible return only when no live stream and swallows reconnect failures", async () => { + /* + FNXC:ChatStreaming 2026-07-26-21:05: + This test used to assert that an attached stream suppressed the visible-return session probe + ENTIRELY ("only when no live stream"). That assertion encoded the latch bug: `streamRef` is cleared + only by the stream's own terminal callbacks, so a transport killed during a background suspend left + a dead stream marked live and every later resume/reconnect was a no-op against a frozen transcript. + The probe now always runs; what an attached, still-generating stream buys is that NOTHING is torn + down or reloaded behind it. Failures are still swallowed (no toast) — with a bounded retry now. + */ + it("probes the session on visible return and leaves a still-generating stream attached, swallowing reconnect failures", async () => { const session = { ...makeSession({ id: "session-001", agentId: "agent-001" }), isGenerating: false, @@ -2259,13 +2268,25 @@ describe("useChat", () => { }); mockFetchChatSession.mockClear(); + mockFetchChatSession.mockResolvedValue({ + session: { ...session, isGenerating: true, inFlightGeneration: null }, + }); + const attachCallsBeforeResume = mockAttachChatStream.mock.calls.length; + const messageLoadsBeforeResume = mockFetchChatMessages.mock.calls.length; + act(() => { result.current.sendMessage("Hello"); setDocumentVisibilityState("hidden"); setDocumentVisibilityState("visible"); }); - expect(mockFetchChatSession).not.toHaveBeenCalled(); + await waitFor(() => { + expect(mockFetchChatSession).toHaveBeenCalledWith("session-001", undefined); + }); + // A stream the server confirms is still generating keeps ownership of the transcript. + expect(mockAttachChatStream.mock.calls.length).toBe(attachCallsBeforeResume); + expect(mockFetchChatMessages.mock.calls.length).toBe(messageLoadsBeforeResume); + expect(addToast).not.toHaveBeenCalled(); }); it("still shows toast for non-suspension errors regardless of visibility", async () => { diff --git a/packages/dashboard/app/hooks/__tests__/useLiveTranscript.resync.test.ts b/packages/dashboard/app/hooks/__tests__/useLiveTranscript.resync.test.ts new file mode 100644 index 0000000000..935bb58d07 --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useLiveTranscript.resync.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +/* +FNXC:TaskTranscript 2026-07-26-20:05: +Reconnect-resync coverage for the live transcript. `/api/tasks/:id/logs/stream` replays nothing, so +the refetched page is the ONLY recovery path — and a page fetched while the live tail is still +running is a snapshot from before those lines. The invariant: a resync may not delete an entry that +arrived during its own fetch, and may not render an entry twice when it is in both the page and the +stream. +*/ + +const { sseOptions } = vi.hoisted(() => ({ + sseOptions: { + current: null as null | { + onReconnect?: () => void; + events: Record void>; + }, + }, +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn((_url: string, opts: never) => { + sseOptions.current = opts; + return () => {}; + }), +})); + +vi.mock("../../api", () => ({ + fetchAgentLogsWithMeta: vi.fn(), +})); + +import { useLiveTranscript } from "../useLiveTranscript"; +import * as apiModule from "../../api"; + +const mockFetchAgentLogsWithMeta = vi.mocked(apiModule.fetchAgentLogsWithMeta); + +function serverEntry(text: string, timestamp: string) { + return { taskId: "FN-001", type: "text" as const, text, timestamp }; +} + +function emitLog(text: string, timestamp: string) { + sseOptions.current?.events["agent:log"]({ + data: JSON.stringify({ type: "text", text, timestamp }), + } as MessageEvent); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("useLiveTranscript reconnect resync", () => { + beforeEach(() => { + vi.useFakeTimers(); + sseOptions.current = null; + mockFetchAgentLogsWithMeta.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("replaces the buffer with the persisted tail on reconnect (newest first)", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: [serverEntry("older", "2026-01-01T00:00:01Z"), serverEntry("newer", "2026-01-01T00:00:02Z")], + total: 2, + hasMore: false, + }); + + const { result } = renderHook(() => useLiveTranscript("FN-001")); + + await act(async () => { + sseOptions.current?.onReconnect?.(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.entries.map((entry) => entry.text)).toEqual(["newer", "older"]); + }); + + it("keeps live entries that raced the resync fetch", async () => { + const page = deferred<{ entries: ReturnType[]; total: number; hasMore: boolean }>(); + mockFetchAgentLogsWithMeta.mockReturnValue(page.promise as never); + + const { result } = renderHook(() => useLiveTranscript("FN-001")); + + act(() => { + sseOptions.current?.onReconnect?.(); + }); + + // Streams in WHILE the authoritative page is still in flight. The page below predates it. + act(() => { + emitLog("raced-live-entry", "2026-01-01T00:00:09Z"); + }); + + await act(async () => { + page.resolve({ + entries: [serverEntry("persisted-1", "2026-01-01T00:00:01Z"), serverEntry("persisted-2", "2026-01-01T00:00:02Z")], + total: 2, + hasMore: false, + }); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.entries.map((entry) => entry.text)).toEqual([ + "raced-live-entry", + "persisted-2", + "persisted-1", + ]); + }); + + it("renders an entry once when it is in both the page and the live stream", async () => { + const page = deferred<{ entries: ReturnType[]; total: number; hasMore: boolean }>(); + mockFetchAgentLogsWithMeta.mockReturnValue(page.promise as never); + + const { result } = renderHook(() => useLiveTranscript("FN-001")); + + act(() => { + sseOptions.current?.onReconnect?.(); + }); + act(() => { + emitLog("persisted-2", "2026-01-01T00:00:02Z"); + }); + + await act(async () => { + page.resolve({ + entries: [serverEntry("persisted-1", "2026-01-01T00:00:01Z"), serverEntry("persisted-2", "2026-01-01T00:00:02Z")], + total: 2, + hasMore: false, + }); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.entries.map((entry) => entry.text)).toEqual(["persisted-2", "persisted-1"]); + }); + + it("flushes parked entries and retries when the resync fetch fails", async () => { + const failing = deferred(); + mockFetchAgentLogsWithMeta.mockReturnValueOnce(failing.promise as never); + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: [serverEntry("persisted-1", "2026-01-01T00:00:01Z")], + total: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useLiveTranscript("FN-001")); + + act(() => { + sseOptions.current?.onReconnect?.(); + }); + act(() => { + emitLog("live-during-failed-fetch", "2026-01-01T00:00:09Z"); + }); + + await act(async () => { + failing.reject(new Error("offline")); + await vi.advanceTimersByTimeAsync(0); + }); + + // The parked entry is rendered, not dropped, even though the page never arrived. + expect(result.current.entries.map((entry) => entry.text)).toEqual(["live-during-failed-fetch"]); + expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledTimes(1); + + // Bounded ladder retries without waiting for another reconnect. + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledTimes(2); + expect(result.current.entries.map((entry) => entry.text)).toEqual(["persisted-1"]); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts b/packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts index 3c2dbf48e7..c16eb8022c 100644 --- a/packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useMultiAgentLogs.test.ts @@ -16,6 +16,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { MAX_LOG_ENTRIES, useMultiAgentLogs } from "../useMultiAgentLogs"; +import { isLogGapMarker } from "../logStreamReconcile"; import { fetchAgentLogsWithMeta } from "../../api"; import { MockEventSource } from "../../../vitest.setup"; @@ -623,4 +624,272 @@ describe("useMultiAgentLogs", () => { expect(lastCall?.[1]).toBe("proj-B"); }); }); + + /* + FNXC:AgentLogResync 2026-07-26-17:45: + Regression cover for the silent log gap on the MULTI-agent surface. `/api/tasks/:id/logs/stream` + replays nothing on open, so every line emitted while the channel is down (SSE error, heartbeat + timeout, or the hidden-tab suspend added for mobile tab retention) used to vanish from a list that + still rendered as contiguous. useAgentLogs was fixed first; this hook subscribes to the SAME + live-only channel and must hold the SAME invariant: after any reconnect the rendered list either + contains the missed lines, in order and without duplicates, or shows an explicit gap marker. + */ + describe("reconnect resync", () => { + const logEntry = (minute: number, text: string) => ({ + timestamp: `2026-01-01T00:${String(minute).padStart(2, "0")}:00Z`, + taskId: "FN-001", + text, + type: "text" as const, + }); + + /** Payload-less transport event; the sse-bus turns a channel's SECOND `open` into onReconnect. */ + function fireOpen(es: MockEventSource) { + es._emit("open"); + } + + it("recovers lines emitted while the stream was suspended, in order and without duplicates", async () => { + const history = [logEntry(0, "hist-1"), logEntry(1, "hist-2")]; + mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: history, total: 2, hasMore: false }); + + const { result } = renderHook(() => useMultiAgentLogs(["FN-001"])); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(2); + }); + + const es = getConnection("FN-001")!; + act(() => { + fireOpen(es); + }); + act(() => { + es._emit("agent:log", logEntry(2, "live-3")); + }); + expect(result.current["FN-001"].entries.map((entry) => entry.text)).toEqual([ + "hist-1", + "hist-2", + "live-3", + ]); + + // Suspended: the server kept writing and the stream delivered none of it. + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: [...history, logEntry(2, "live-3"), logEntry(3, "missed-4"), logEntry(4, "missed-5")], + total: 5, + hasMore: false, + }); + + await act(async () => { + fireOpen(es); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current["FN-001"].entries.map((entry) => entry.text)).toEqual([ + "hist-1", + "hist-2", + "live-3", + "missed-4", + "missed-5", + ]); + }); + expect(new Set(result.current["FN-001"].entries.map((entry) => entry.text)).size).toBe(5); + expect(result.current["FN-001"].entries.some(isLogGapMarker)).toBe(false); + }); + + it("splices the reconnect page onto paged-back history instead of replacing the buffer", async () => { + // 150 entries held (100 initial + one "load older" page). The reconnect page is the newest 100 + // plus 2 lines emitted while suspended; the 50 oldest must survive the merge. + const all = Array.from({ length: 152 }, (_, index) => logEntry(index, `entry-${index}`)); + const held = all.slice(0, 150); + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: held.slice(50), + total: 150, + hasMore: true, + }); + + const { result } = renderHook(() => useMultiAgentLogs(["FN-001"])); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(100); + }); + + mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: held.slice(0, 50), total: 150, hasMore: false }); + await act(async () => { + await result.current["FN-001"].loadMore(); + }); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(150); + }); + + const es = getConnection("FN-001")!; + act(() => { + fireOpen(es); + }); + + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: all.slice(52), + total: 152, + hasMore: true, + }); + + await act(async () => { + fireOpen(es); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(152); + }); + const texts = result.current["FN-001"].entries.map((entry) => entry.text); + expect(texts).toEqual(all.map((entry) => entry.text)); + expect(new Set(texts).size).toBe(152); + expect(result.current["FN-001"].entries.some(isLogGapMarker)).toBe(false); + }); + + it("renders an explicit gap marker when the missed window exceeds one authoritative page", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: [logEntry(0, "before-suspend")], + total: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useMultiAgentLogs(["FN-001"])); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(1); + }); + + const es = getConnection("FN-001")!; + act(() => { + fireOpen(es); + }); + + // The newest page shares nothing with the buffer: more than one page was missed. + const authoritativePage = Array.from({ length: INITIAL_LOAD_LIMIT }, (_, index) => + logEntry(index + 5, `missed-${index}`), + ); + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: authoritativePage, + total: 401, + hasMore: true, + }); + + await act(async () => { + fireOpen(es); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(INITIAL_LOAD_LIMIT + 1); + }); + + const entries = result.current["FN-001"].entries; + expect(isLogGapMarker(entries[0])).toBe(true); + expect(entries[0].text.length).toBeGreaterThan(0); + expect(entries.slice(1).map((entry) => entry.text)).toEqual( + authoritativePage.map((entry) => entry.text), + ); + // The unreconcilable prefix is not silently glued onto a page it does not touch. + expect(entries.some((entry) => entry.text === "before-suspend")).toBe(false); + expect(result.current["FN-001"].hasMore).toBe(true); + }); + + it("does not duplicate a live entry that races the reconnect refetch", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false }); + + const { result } = renderHook(() => useMultiAgentLogs(["FN-001"])); + + await waitFor(() => { + expect(result.current["FN-001"].loading).toBe(false); + }); + + const es = getConnection("FN-001")!; + act(() => { + fireOpen(es); + }); + + const raced = logEntry(1, "raced"); + let resolveResync: ((value: { entries: typeof raced[]; total: number; hasMore: boolean }) => void) | undefined; + mockFetchAgentLogsWithMeta.mockImplementation( + () => new Promise((resolve) => { + resolveResync = resolve as typeof resolveResync; + }), + ); + + act(() => { + fireOpen(es); + }); + // Arrives while the authoritative refetch is still in flight, and is also in that page. + act(() => { + es._emit("agent:log", raced); + }); + + await act(async () => { + resolveResync?.({ entries: [logEntry(0, "hist-1"), raced], total: 2, hasMore: false }); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current["FN-001"].entries.map((entry) => entry.text)).toEqual(["hist-1", "raced"]); + }); + }); + + it("pages older entries in below the gap marker and retires it", async () => { + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: [logEntry(0, "before-suspend")], + total: 1, + hasMore: false, + }); + + const { result } = renderHook(() => useMultiAgentLogs(["FN-001"])); + + await waitFor(() => { + expect(result.current["FN-001"].entries).toHaveLength(1); + }); + + const es = getConnection("FN-001")!; + act(() => { + fireOpen(es); + }); + + const authoritativePage = [logEntry(9, "newest-1"), logEntry(10, "newest-2")]; + mockFetchAgentLogsWithMeta.mockResolvedValue({ + entries: authoritativePage, + total: 12, + hasMore: true, + }); + + await act(async () => { + fireOpen(es); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(isLogGapMarker(result.current["FN-001"].entries[0])).toBe(true); + }); + + const older = [logEntry(7, "older-1"), logEntry(8, "older-2")]; + mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: older, total: 12, hasMore: false }); + + await act(async () => { + await result.current["FN-001"].loadMore(); + }); + + await waitFor(() => { + expect(result.current["FN-001"].entries.map((entry) => entry.text)).toEqual([ + "older-1", + "older-2", + "newest-1", + "newest-2", + ]); + }); + // Offset must count REAL entries only; the client-only marker would shift the page by one. + expect(mockFetchAgentLogsWithMeta).toHaveBeenLastCalledWith("FN-001", undefined, { + limit: INITIAL_LOAD_LIMIT, + offset: 2, + }); + expect(result.current["FN-001"].entries.some(isLogGapMarker)).toBe(false); + expect(result.current["FN-001"].hasMore).toBe(false); + }); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts index 0a8b3877ef..64929c2c2e 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks-hydration-freshness.test.ts @@ -14,7 +14,7 @@ render, not just after a fetch. Asserted against real localStorage and the real a mocked cache is what let the missing `savedAt` plumbing hide. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import type { Task } from "@fusion/core"; import { useTasks } from "../useTasks"; import * as api from "../../api"; @@ -22,6 +22,34 @@ import { SWR_CACHE_KEYS } from "../../utils/swrCache"; import { isTaskStuck, countStuckTasks } from "../../utils/taskStuck"; import { isTaskAgentActive } from "../../utils/taskActivity"; +/* +FNXC:MobileTabDiscard 2026-07-26-16:40: +The SSE bus is faked (rather than driven through EventSource) so a test can deliver ONE task event at a +chosen moment and inspect the freshness clock. `vi.hoisted` is required: the factory runs when +`../useTasks` is imported, before module-scope `const`s initialize. +*/ +type SseEventHandlers = Record void) | undefined>; +const sseHarness = vi.hoisted(() => ({ subscriptions: [] as Record[] })); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: (_url: string, sub: { events?: Record } = {}) => { + const events = sub.events ?? {}; + sseHarness.subscriptions.push(events); + return () => { + const index = sseHarness.subscriptions.indexOf(events); + if (index >= 0) sseHarness.subscriptions.splice(index, 1); + }; + }, +})); + +function emitSse(event: string, payload: unknown): void { + act(() => { + for (const events of [...sseHarness.subscriptions]) { + (events as SseEventHandlers)[event]?.({ data: JSON.stringify(payload) } as MessageEvent); + } + }); +} + vi.mock("../../api", async (importOriginal) => { const { createDashboardApiMock } = await import("../../test/mockApi"); return createDashboardApiMock(() => importOriginal(), { @@ -75,6 +103,7 @@ function seedSnapshot(tasks: Task[], ageMs: number): number { } beforeEach(() => { + sseHarness.subscriptions.length = 0; MockEventSource.instances = []; (globalThis as unknown as { EventSource: unknown }).EventSource = MockEventSource; localStorage.clear(); @@ -197,3 +226,84 @@ describe("useTasks hydration freshness (dataAsOfMs)", () => { expect(result.current.lastFetchTimeMs).toBe(otherSavedAt); }); }); + +/* +FNXC:MobileTabDiscard 2026-07-26-16:40: +`lastFetchTimeMs` is read as the as-of time of ALL rows in `tasks`, but four writers advanced it to +`Date.now()` on learning about ONE row (task:created / task:moved / task:updated, and +`ingestCreatedTasks`). While a hydrated hours-old board waits for the mount revalidation on a waking +radio, a single unrelated event re-stamped the entire board as measured-from-now and every in-progress +card rendered 'stuck' again — the seeding regression above, reached through a sibling path. + +The rule under test: a single-row live update may advance the clock only AFTER a full fetch has confirmed +every row. It must still advance after that, or stuck detection would silently stop firing for the rest +of a long SSE session (this hook has no periodic poll). +*/ +describe("useTasks freshness clock vs single-row live updates", () => { + const eventCases: [string, (task: Task) => unknown][] = [ + ["task:created", (task) => task], + ["task:updated", (task) => task], + ["task:moved", (task) => ({ task, from: "todo", to: "in-progress" })], + ]; + + it.each(eventCases)( + "%s does not advance the clock while an unconfirmed hydrated snapshot is on screen", + (eventName, buildPayload) => { + const savedAt = Date.now() - TWO_HOURS_MS; + const hydrated = Array.from({ length: 3 }, (_, index) => + createInProgressTask(`FN-${index}`, savedAt - 60_000), + ); + seedSnapshot(hydrated, TWO_HOURS_MS); + // Never resolves: the mount revalidation is still in flight, exactly as on a waking radio. + mockFetchTasks.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID })); + expect(result.current.lastFetchTimeMs).toBe(savedAt); + + emitSse(eventName, buildPayload(createInProgressTask("FN-LIVE", Date.now()))); + + // The event was applied... + expect(result.current.tasks.map((task) => task.id)).toContain("FN-LIVE"); + // ...but it says nothing about the other three rows, so the board's age is unchanged. + expect(result.current.lastFetchTimeMs).toBe(savedAt); + const stuckHydrated = hydrated.filter((task) => + isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, result.current.lastFetchTimeMs), + ); + expect(stuckHydrated).toEqual([]); + expect(countStuckTasks(result.current.tasks, TASK_STUCK_TIMEOUT_MS, result.current.lastFetchTimeMs)).toBe(0); + }, + ); + + it("ingestCreatedTasks does not advance the clock while the snapshot is unconfirmed", () => { + const savedAt = Date.now() - TWO_HOURS_MS; + seedSnapshot([createInProgressTask("FN-0", savedAt - 60_000)], TWO_HOURS_MS); + mockFetchTasks.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID })); + + act(() => { + result.current.ingestCreatedTasks([createInProgressTask("FN-INGESTED", Date.now())]); + }); + + expect(result.current.tasks.map((task) => task.id)).toContain("FN-INGESTED"); + expect(result.current.lastFetchTimeMs).toBe(savedAt); + }); + + it("still advances the clock on a live update once a fetch has confirmed the whole board", async () => { + seedSnapshot([createInProgressTask("FN-0", Date.now() - TWO_HOURS_MS)], TWO_HOURS_MS); + mockFetchTasks.mockResolvedValue([createInProgressTask("FN-CONFIRMED", Date.now())]); + + const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID })); + await waitFor(() => { + expect(result.current.tasks.map((task) => task.id)).toEqual(["FN-CONFIRMED"]); + }); + const confirmedAt = result.current.lastFetchTimeMs; + expect(confirmedAt).toBeDefined(); + + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(Date.now() + 5 * 60_000); + emitSse("task:updated", createInProgressTask("FN-CONFIRMED", Date.now())); + + expect(result.current.lastFetchTimeMs!).toBeGreaterThan(confirmedAt!); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useTasks-stale-hydration.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks-stale-hydration.test.ts index e535614e9e..c85fb893dd 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks-stale-hydration.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks-stale-hydration.test.ts @@ -123,9 +123,10 @@ describe("useTasks stale snapshot hydration (mobile tab discard)", () => { expect(mockFetchTasks).toHaveBeenCalledTimes(1); }); - it("clears the entry when the revalidation fails so the next mount does not re-hydrate it", async () => { + it("clears the entry when a revalidation that REACHED THE SERVER fails, so the next mount does not re-hydrate it", async () => { seedSnapshot([createTask({ id: "FN-STALE" })], FIVE_MINUTES_MS); - mockFetchTasks.mockRejectedValue(new Error("offline")); + // A non-2xx response: the server answered and the snapshot is unverifiable. + mockFetchTasks.mockRejectedValue(new Error("Request failed: 500")); const first = renderHook(() => useTasks({ projectId: PROJECT_ID })); expect(first.result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]); @@ -134,12 +135,58 @@ describe("useTasks stale snapshot hydration (mobile tab discard)", () => { expect(first.result.current.lastRefreshErrorAt).not.toBeNull(); }); expect(localStorage.getItem(CACHE_KEY)).toBeNull(); + expect(first.result.current.tasks).toEqual([]); first.unmount(); const second = renderHook(() => useTasks({ projectId: PROJECT_ID })); expect(second.result.current.tasks).toEqual([]); }); + /* + FNXC:MobileTabDiscard 2026-07-26-16:40: + The failure mode this whole cache exists to survive. The mount revalidation fires on a just-woken + mobile radio, where the first fetch routinely rejects at the transport layer ("Load failed" on iOS + Safari, "Failed to fetch" on Chrome Android). That rejection says nothing about the snapshot, but the + `clearOnError` catch used to delete the entry AND blank the hydrated board — so the restore went white + and the NEXT restore had nothing left to hydrate either. + */ + describe.each([ + ["an iOS suspension rejection", () => new TypeError("Load failed"), false], + ["a Chrome Android suspension rejection", () => new TypeError("Failed to fetch"), false], + ["an offline device", () => new Error("boom"), true], + ])("when mount revalidation fails with %s", (_label, makeError, forceOffline) => { + beforeEach(() => { + if (!forceOffline) return; + // Own property shadows jsdom's Navigator.prototype getter; deleting it restores the getter. + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + }); + + afterEach(() => { + if (!forceOffline) return; + delete (navigator as unknown as Record).onLine; + }); + + it("keeps both the painted board and the snapshot so the next restore still hydrates", async () => { + seedSnapshot([createTask({ id: "FN-STALE" })], FIVE_MINUTES_MS); + mockFetchTasks.mockRejectedValue(makeError()); + + const first = renderHook(() => useTasks({ projectId: PROJECT_ID })); + expect(first.result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]); + + await waitFor(() => { + expect(first.result.current.lastRefreshErrorAt).not.toBeNull(); + }); + // The board must not blank behind the failed revalidation. + expect(first.result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]); + expect(localStorage.getItem(CACHE_KEY)).not.toBeNull(); + first.unmount(); + + // The restore after this one is the real regression: it must not start from []. + const second = renderHook(() => useTasks({ projectId: PROJECT_ID })); + expect(second.result.current.tasks.map((task) => task.id)).toEqual(["FN-STALE"]); + }); + }); + it("still persists a snapshot when the full board exceeds the write budget", async () => { // ~2.5KB of log/description bulk per row across 400 rows blows past the 500KB envelope cap. const heavyTasks = Array.from({ length: 400 }, (_, index) => diff --git a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts index 9e143ce8af..e268fa9b1d 100644 --- a/packages/dashboard/app/hooks/__tests__/useTasks.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTasks.test.ts @@ -699,18 +699,34 @@ describe("useTasks", () => { expect(mockFetchTasks).not.toHaveBeenCalled(); expect(MockEventSource.instances).toHaveLength(1); - act(() => { - MockEventSource.instances[0]._emit("open"); - MockEventSource.instances[0]._emit("error"); - }); + // The resync fires when the REBUILT stream opens, not on the error (see the FNXC note above). + // RECONNECT_DELAY_MS is 3s, so drive it with fake timers rather than waiting in real time; the + // fake clock must be installed BEFORE the error schedules the reconnect timer. + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + act(() => { + MockEventSource.instances[0]._emit("open"); + MockEventSource.instances[0]._emit("error"); + }); + + await act(async () => { + vi.advanceTimersByTime(3_000); + await flushPromises(); + }); + expect(MockEventSource.instances).toHaveLength(2); + await act(async () => { + MockEventSource.instances[1]._emit("open"); + await flushPromises(); + }); - await waitFor(() => { expect(mockFetchTasks).toHaveBeenCalledTimes(1); - }); - expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, undefined, undefined, false); - await waitFor(() => { - expect(result.current.tasks[0]?.id).toBe("FN-RECONNECTED"); - }); + expect(mockFetchTasks).toHaveBeenLastCalledWith(undefined, undefined, undefined, undefined, false); + await waitFor(() => { + expect(result.current.tasks[0]?.id).toBe("FN-RECONNECTED"); + }); + } finally { + vi.useRealTimers(); + } unmount(); }); @@ -905,6 +921,7 @@ describe("useTasks", () => { const first = MockEventSource.instances[0]; act(() => { + first._emit("open"); first._emit("error"); }); @@ -916,6 +933,17 @@ describe("useTasks", () => { }); expect(MockEventSource.instances).toHaveLength(2); + /* + FNXC:DashboardSSE 2026-07-26-11:25: + The resync signal is now emitted by the REBUILT stream's `open`, not by the error that tore the old + one down (a failed reconnect must not claim to have resynced). vitest.setup's MockEventSource marks + itself OPEN in its constructor but never dispatches `open` like a real EventSource, so the test has + to emit it on the replacement instance. + */ + await act(async () => { + MockEventSource.instances[1]._emit("open"); + await flushPromises(); + }); expect(mockFetchTasks).toHaveBeenCalledTimes(2); unmount(); @@ -948,6 +976,7 @@ describe("useTasks", () => { const first = MockEventSource.instances[0]; act(() => { + first._emit("open"); first._emit("error"); }); @@ -957,6 +986,11 @@ describe("useTasks", () => { }); expect(MockEventSource.instances).toHaveLength(2); + // See the FNXC note above: the rebuilt stream's `open` is the resync authority. + await act(async () => { + MockEventSource.instances[1]._emit("open"); + await flushPromises(); + }); expect(mockFetchTasks).toHaveBeenCalledTimes(2); expect(result.current.tasks[0]?.title).toBe("Fresh title"); }); @@ -3767,7 +3801,10 @@ describe("useTasks", () => { act(() => { es._emit("error"); }); - expect(mockFetchTasks).toHaveBeenCalledTimes(1); // onReconnect fires once during error + // The error alone no longer resyncs — only the rebuilt stream's `open` does (see the FNXC note + // on the reconnect tests above). This test's real guarantee is the one below: after sseEnabled + // flips off, the pending reconnect can never produce a refetch. + expect(mockFetchTasks).toHaveBeenCalledTimes(0); // Before the reconnect timer fires, flip sseEnabled to false await act(async () => { @@ -3777,8 +3814,8 @@ describe("useTasks", () => { // Advance timers past RECONNECT_DELAY_MS (3 seconds) vi.advanceTimersByTime(4_000); - // No additional fetchTasks should have been called — active flag blocked it - expect(mockFetchTasks).toHaveBeenCalledTimes(1); + // No fetchTasks should have been called — active flag blocked it + expect(mockFetchTasks).toHaveBeenCalledTimes(0); vi.useRealTimers(); }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useViewState.test.ts b/packages/dashboard/app/hooks/__tests__/useViewState.test.ts index dfda2feab6..88bd5315da 100644 --- a/packages/dashboard/app/hooks/__tests__/useViewState.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useViewState.test.ts @@ -695,4 +695,59 @@ describe("useViewState", () => { // Project A's view should still be insights (not affected by project B load) expect(resultA.current.taskView).toBe("insights"); }); + + // ── Session task-view copy is project-scoped ONLY ───────────────── + /* + FNXC:ViewState 2026-07-26-20:50: + The per-tab session copy documented itself as "never mirrored unscoped", but `scopedKey(base, + undefined)` returns the BARE key and the persist effect runs during boot and every project-switch + window with `currentProject === undefined` — so it DID write the unscoped mirror, and the + initializer read it back for first paint. That is the leak the doc forbade: the previous project's + view landing in the next project's session. These tests pin both halves (never written, never read) + so the claim cannot go back to being aspirational. + */ + it("never writes the unscoped session task-view mirror while the project is unknown", async () => { + const { result } = renderHook(() => useViewState(createOptions({ currentProject: null }))); + + await act(async () => { + result.current.handleChangeTaskView("insights"); + }); + + expect(result.current.taskView).toBe("insights"); + expect(sessionStorage.getItem("kb-dashboard-task-view-session")).toBeNull(); + expect(sessionStorage.length).toBe(0); + }); + + it("still writes the project-scoped session copy once a project is known", async () => { + const { result } = renderHook(() => useViewState(createOptions({ currentProject: PROJECT }))); + + await act(async () => { + result.current.handleChangeTaskView("insights"); + }); + + expect(sessionStorage.getItem("kb:proj_123:kb-dashboard-task-view-session")).toBe("insights"); + expect(sessionStorage.getItem("kb-dashboard-task-view-session")).toBeNull(); + }); + + it("never paints an unscoped session task-view left behind by an earlier build", async () => { + // A pre-fix build (or another project's tab) wrote the bare key. + sessionStorage.setItem("kb-dashboard-task-view-session", "insights"); + localStorage.setItem("kb-dashboard-task-view", "list"); + localStorage.setItem("kb:proj_123:kb-dashboard-task-view", "list"); + + // Every rendered value, so the FIRST PAINT (the initializer's value) is asserted too — the + // unscoped read only ever affected first paint, which a post-effect assertion cannot see. + const painted: string[] = []; + const { result } = renderHook(() => { + const state = useViewState(createOptions({ currentProject: PROJECT })); + painted.push(state.taskView); + return state; + }); + + await waitFor(() => { + expect(result.current.taskView).toBe("list"); + }); + expect(painted[0]).toBe("list"); + expect(painted).not.toContain("insights"); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/visibility-poll-gating.test.ts b/packages/dashboard/app/hooks/__tests__/visibility-poll-gating.test.ts index 53d5db9036..90d9ab595d 100644 --- a/packages/dashboard/app/hooks/__tests__/visibility-poll-gating.test.ts +++ b/packages/dashboard/app/hooks/__tests__/visibility-poll-gating.test.ts @@ -21,6 +21,9 @@ const fetchActivityLog = vi.fn(); const fetchActivityFeed = vi.fn(); const fetchProjectHealth = vi.fn(); const apiFn = vi.fn(); +const fetchProjectsAcrossNodes = vi.fn(); +const fetchNodes = vi.fn(); +const fetchMeshState = vi.fn(); vi.mock("../../api", () => ({ fetchDashboardHealth: (...a: unknown[]) => fetchDashboardHealth(...a), @@ -29,12 +32,53 @@ vi.mock("../../api", () => ({ fetchActivityFeed: (...a: unknown[]) => fetchActivityFeed(...a), fetchProjectHealth: (...a: unknown[]) => fetchProjectHealth(...a), api: (...a: unknown[]) => apiFn(...a), + // Surfaces added when useProjects / useNodes / useMeshState were migrated onto the shared gate. + fetchProjectsAcrossNodes: (...a: unknown[]) => fetchProjectsAcrossNodes(...a), + hasNodeMappingsSupport: () => false, + registerProject: vi.fn(), + updateProject: vi.fn(), + unregisterProject: vi.fn(), + fetchNodes: (...a: unknown[]) => fetchNodes(...a), + registerNode: vi.fn(), + updateNode: vi.fn(), + unregisterNode: vi.fn(), + checkNodeHealth: vi.fn(), + discoverRemoteNodeProjects: vi.fn(), + fetchDockerNodeConfig: vi.fn(), + fetchDockerConfigDiff: vi.fn(), + updateDockerNodeConfig: vi.fn(), + fetchMeshState: (...a: unknown[]) => fetchMeshState(...a), })); -import { __resetVisibleEdgeStaggerRegistryForTests, useVisibilityAwarePoll } from "../visibilitySuspension"; +vi.mock("../../api-node", () => ({ + persistNodeProjectPathMappings: vi.fn(), +})); + +vi.mock("../../utils/resumeInstrumentation", () => ({ + recordResumeEvent: vi.fn(), +})); + +// `t` must be referentially stable: `useNodes`/`useMeshState` list it in effect deps, so a fresh function +// per render would re-run their load effect and fabricate fetches this test would misattribute to polling. +const translate = (_key: string, fallback?: string) => fallback ?? _key; +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: translate }), +})); + +import { + VISIBLE_EDGE_STAGGER_SLOTS, + VISIBLE_EDGE_STAGGER_STEP_MS, + VISIBLE_EDGE_STAGGER_WINDOW_MS, + __resetVisibleEdgeStaggerRegistryForTests, + useVisibilityAwarePoll, + visibleEdgeStaggerDelayMs, +} from "../visibilitySuspension"; import { useActivityLog } from "../useActivityLog"; import { useDashboardHealth } from "../useDashboardHealth"; +import { useMeshState } from "../useMeshState"; +import { useNodes } from "../useNodes"; import { useProjectHealth } from "../useProjectHealth"; +import { useProjects } from "../useProjects"; import { useStashOrphanCount } from "../useStashOrphanCount"; /** Drive the jsdom visibility state and dispatch the event the gate listens for. */ @@ -51,7 +95,16 @@ describe("polling loops are suspended while the document is hidden", () => { vi.useFakeTimers(); __resetVisibleEdgeStaggerRegistryForTests(); setVisibility("visible"); - for (const fn of [fetchDashboardHealth, fetchActivityLog, fetchActivityFeed, fetchProjectHealth, apiFn]) { + for (const fn of [ + fetchDashboardHealth, + fetchActivityLog, + fetchActivityFeed, + fetchProjectHealth, + apiFn, + fetchProjectsAcrossNodes, + fetchNodes, + fetchMeshState, + ]) { fn.mockReset(); } fetchDashboardHealth.mockResolvedValue({ status: "ok" }); @@ -59,6 +112,9 @@ describe("polling loops are suspended while the document is hidden", () => { fetchActivityFeed.mockResolvedValue([]); fetchProjectHealth.mockResolvedValue({ ok: true }); apiFn.mockResolvedValue({ count: 0 }); + fetchProjectsAcrossNodes.mockResolvedValue([]); + fetchNodes.mockResolvedValue([]); + fetchMeshState.mockResolvedValue({ nodes: [] }); }); afterEach(() => { @@ -149,6 +205,33 @@ describe("polling loops are suspended while the document is hidden", () => { render: () => renderHook(() => useStashOrphanCount("p1")), spy: () => apiFn, }, + /* + FNXC:MobileTabRetention 2026-07-26-16:05: + These three were the gap between the change set's claim ("every polling loop is visibility-gated") and + its behavior. Each owned a `visibilitychange` listener that only ADDED a refresh-on-visible and never + cleared its interval, so all three kept fetching the whole time the tab was backgrounded. `useProjects` + is the worst of them: App.tsx mounts it unconditionally, so its 5s loop alone kept the page non-idle for + the entire session — the precise condition that makes a mobile browser discard the tab. Against the + pre-fix code the hidden-window assertion below fails (6 poll periods of fetches, not zero). + */ + { + name: "useProjects (5s project-list poll, mounted for the whole session)", + intervalMs: 5_000, + render: () => renderHook(() => useProjects()), + spy: () => fetchProjectsAcrossNodes, + }, + { + name: "useNodes (10s node-registry poll)", + intervalMs: 10_000, + render: () => renderHook(() => useNodes()), + spy: () => fetchNodes, + }, + { + name: "useMeshState (10s mesh-state poll)", + intervalMs: 10_000, + render: () => renderHook(() => useMeshState()), + spy: () => fetchMeshState, + }, ]; for (const surface of surfaces) { @@ -237,6 +320,25 @@ describe("the hidden -> visible edge is staggered, not a synchronized stampede", return ticks.filter((tick) => tick.mock.calls.length > 0).length; } + /* + FNXC:MobileTabRetention 2026-07-26-16:05: + The stagger geometry is shared with the SSE bus's visible-edge channel reopen, so the pure slot function is + asserted directly: both fan-outs must derive delays from it rather than hand-roll a second stagger whose + union with this one would be unbounded. + */ + it("exposes a deterministic, bounded slot delay for other visible-edge fan-outs to reuse", () => { + expect(VISIBLE_EDGE_STAGGER_SLOTS).toBe(Math.round(VISIBLE_EDGE_STAGGER_WINDOW_MS / VISIBLE_EDGE_STAGGER_STEP_MS)); + expect(visibleEdgeStaggerDelayMs(0)).toBe(0); + expect(visibleEdgeStaggerDelayMs(1)).toBe(VISIBLE_EDGE_STAGGER_STEP_MS); + expect(visibleEdgeStaggerDelayMs(3)).toBe(3 * VISIBLE_EDGE_STAGGER_STEP_MS); + // Wraps instead of growing without bound, so a large fan-out stays inside the window. + expect(visibleEdgeStaggerDelayMs(VISIBLE_EDGE_STAGGER_SLOTS)).toBe(0); + expect(visibleEdgeStaggerDelayMs(VISIBLE_EDGE_STAGGER_SLOTS * 4 + 2)).toBe(2 * VISIBLE_EDGE_STAGGER_STEP_MS); + for (let index = 0; index < VISIBLE_EDGE_STAGGER_SLOTS * 3; index += 1) { + expect(visibleEdgeStaggerDelayMs(index)).toBeLessThan(VISIBLE_EDGE_STAGGER_WINDOW_MS); + } + }); + it("N background consumers do not all fire in the same tick, and all refresh within the window", async () => { const N = 8; const ticks = Array.from({ length: N }, () => vi.fn()); diff --git a/packages/dashboard/app/hooks/logStreamReconcile.ts b/packages/dashboard/app/hooks/logStreamReconcile.ts new file mode 100644 index 0000000000..734b13e2cb --- /dev/null +++ b/packages/dashboard/app/hooks/logStreamReconcile.ts @@ -0,0 +1,337 @@ +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(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)[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, + }; +} diff --git a/packages/dashboard/app/hooks/resyncRetry.ts b/packages/dashboard/app/hooks/resyncRetry.ts new file mode 100644 index 0000000000..76a8b0a5f8 --- /dev/null +++ b/packages/dashboard/app/hooks/resyncRetry.ts @@ -0,0 +1,116 @@ +/* +FNXC:SseResync 2026-07-26-18:05: +ONE bounded retry ladder for every "the SSE channel just reopened, refetch what we missed" handler. + +Why it exists: those handlers were each written as `void refetch().catch(() => {})` with a comment +saying the next reconnect retries. That claim is false in the case that matters — after a mobile +hidden-tab suspend the stream reopens ONCE, and if that single refetch fails (the same flaky network +that caused the suspend) the surface stays silently wrong until the operator reloads. For the +approval banner that means a pending decision is never rendered and the requesting agent blocks +indefinitely. + +Bounded, not unbounded: the resume edge is already congested (every suspended channel refetches at +the same instant), so a failing surface gets a short ladder and then STOPS and reports degradation +via `onExhausted` — it does not hammer the server. + +Shared rather than copied: three near-identical hand-rolled fixes in one change set is the documented +recurring failure mode here (AGENTS.md "Reuse Components, Design Tokens, and Systems (No Drift)"), so +every resync handler imports this runner instead of growing its own variant. +*/ + +/** Delays before retry attempts 2 and 3. Two retries ≈ 10s of coverage for a transient failure. */ +export const DEFAULT_RESYNC_RETRY_DELAYS_MS: readonly number[] = [2_000, 8_000]; + +export interface ResyncRetryOptions { + /** One resync attempt. MUST reject on failure — a promise that swallows its own error is not retryable. */ + run: () => Promise; + /** Delay before each retry. Length bounds the number of retries (default: two). */ + delaysMs?: readonly number[]; + /** + * Every attempt in the ladder failed. The surface's data is now KNOWN to be possibly incomplete; + * callers use this to raise an operator-visible signal rather than render a confident wrong view. + */ + onExhausted?: (error: unknown) => void; + /** An attempt succeeded after a previous ladder had been exhausted. Callers clear their signal. */ + onRecovered?: () => void; +} + +export interface ResyncRetryRunner { + /** Run the resync now, cancelling any pending retry (a fresh reconnect supersedes a scheduled one). */ + trigger: () => void; + /** Cancel pending retries and suppress all further callbacks. Call from effect cleanup. */ + dispose: () => void; +} + +export function createResyncRetryRunner(options: ResyncRetryOptions): ResyncRetryRunner { + const delays = options.delaysMs ?? DEFAULT_RESYNC_RETRY_DELAYS_MS; + let timer: ReturnType | null = null; + let inFlight = false; + let disposed = false; + // True once a full ladder failed; drives the single onRecovered edge. + let exhausted = false; + + const clearTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const onFailure = (retryIndex: number, error: unknown) => { + inFlight = false; + if (disposed) return; + if (retryIndex < delays.length) { + clearTimer(); + timer = setTimeout(() => { + timer = null; + attempt(retryIndex + 1); + }, delays[retryIndex]); + return; + } + exhausted = true; + options.onExhausted?.(error); + }; + + const onSuccess = () => { + inFlight = false; + if (disposed) return; + if (exhausted) { + exhausted = false; + options.onRecovered?.(); + } + }; + + function attempt(retryIndex: number): void { + // Overlapping attempts are pointless: they converge on the same server state and double the + // load on the resume edge this ladder is trying not to congest. + if (disposed || inFlight) return; + inFlight = true; + /* + `run()` is invoked SYNCHRONOUSLY, never deferred through a microtask. Callers set their + "resync in flight — park live events" flag in the first synchronous statement of run(), and an + event delivered during an intervening microtask would slip past that flag and then be overwritten + by the page the resync applies: exactly the silent data loss these resyncs exist to prevent. + */ + let started: Promise; + try { + started = options.run(); + } catch (error: unknown) { + onFailure(retryIndex, error); + return; + } + void started.then(onSuccess, (error: unknown) => onFailure(retryIndex, error)); + } + + return { + trigger: () => { + if (disposed) return; + clearTimer(); + attempt(0); + }, + dispose: () => { + disposed = true; + clearTimer(); + }, + }; +} diff --git a/packages/dashboard/app/hooks/useActivityLog.ts b/packages/dashboard/app/hooks/useActivityLog.ts index d75d55622d..c946a5d492 100644 --- a/packages/dashboard/app/hooks/useActivityLog.ts +++ b/packages/dashboard/app/hooks/useActivityLog.ts @@ -140,13 +140,24 @@ export function useActivityLog(options: UseActivityLogOptions = {}): UseActivity FNXC:MobileTabRetention 2026-07-26-10:22: loadMore appended pages unbounded, so a long session grew the entry array without limit and inflated the page's resident set — large-memory pages are the second discard trigger on mobile. Cap retained - entries at MAX_RETAINED_ENTRIES (same bound as useMultiAgentLogs). The head of the array is the - current refresh page the operator is looking at, and loadMore only appends continuation pages, so the - cap truncates the tail rather than evicting what is on screen. + entries at MAX_RETAINED_ENTRIES (same bound as useMultiAgentLogs). + + FNXC:ActivityLogPaging 2026-07-26-18:30: + CORRECTION to the note above, which claimed "the cap truncates the tail rather than evicting what is + on screen" and used `merged.slice(0, MAX_RETAINED_ENTRIES)`. That was wrong in the only case where + the cap actually bites: the tail being truncated IS the page loadMore just fetched. At the cap + (limit 50 -> the 11th click) every further click fetched 50 older entries, dropped all 50, advanced + `lastTimestampRef` past them, and left `hasMore` true — so the feed stopped paginating while still + offering a "Load more" button that provably did nothing, and the skipped entries became unreachable + because the cursor had already moved beyond them. + Drop from the HEAD instead (`slice(-MAX)`): the appended older page survives, so the control keeps + doing what it exists for — paging backwards. Evicting the newest entries is the recoverable + direction: `refresh` (manual, and the 5s visibility-aware poll) refetches the newest page from + offset 0 and resets the buffer, so anything dropped off the head comes straight back. */ setEntries((prev) => { const merged = [...prev, ...data]; - return merged.length > MAX_RETAINED_ENTRIES ? merged.slice(0, MAX_RETAINED_ENTRIES) : merged; + return merged.length > MAX_RETAINED_ENTRIES ? merged.slice(-MAX_RETAINED_ENTRIES) : merged; }); setHasMore(data.length === limit); diff --git a/packages/dashboard/app/hooks/useAgentLogs.ts b/packages/dashboard/app/hooks/useAgentLogs.ts index 3c59c2c707..e4d0c49262 100644 --- a/packages/dashboard/app/hooks/useAgentLogs.ts +++ b/packages/dashboard/app/hooks/useAgentLogs.ts @@ -3,178 +3,56 @@ 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: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 matches the caps already enforced by useMultiAgentLogs and useDevServerLogs — one number so the -per-surface memory ceiling stays predictable. -*/ -export const MAX_LOG_ENTRIES = 500; +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. -/** - * 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 and command-center streams can share this one implementation instead - * of each re-deriving the same `slice(-N)` (see AGENTS.md "Reuse Components ... (No Drift)"). - */ -export function capLogEntries(entries: T[], cap: number = MAX_LOG_ENTRIES): T[] { - return entries.length > cap ? entries.slice(-cap) : entries; -} +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-14:05: -`/api/tasks/:id/logs/stream` is a LIVE-ONLY channel: it pipes `agent:log` events as they happen and -sends nothing but `: connected` on open — no ring buffer, no Last-Event-ID replay. Every line the -server emitted while the channel was down is therefore unrecoverable from the socket, and after the -hidden-tab SSE suspend (SSE_HIDDEN_SUSPEND_DELAY_MS) that window is minutes long on desktop as well -as mobile. Before this, `onReconnect` only recorded an instrumentation event, so the rendered list -silently skipped those lines while still LOOKING contiguous. -On reconnect the hook now refetches the authoritative newest page over the existing REST path -(`fetchAgentLogsWithMeta`, offset 0) and splices it onto the buffer. Where the splice cannot be -proven — the missed window is larger than one page, so the refetched page shares no entries with what -the reader already has — the buffer is resynced to the authoritative page behind a VISIBLE gap -marker. A visible gap plus a working "load older" is always preferable to implied continuity the -client cannot guarantee. +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. */ -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 hook uses 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)[LOG_GAP_MARKER_FLAG] === true; -} - -function createLogGapMarker(taskId: string, timestamp: string): AgentLogGapMarker { - return { timestamp, taskId, text: LOG_GAP_MARKER_TEXT, type: "status", [LOG_GAP_MARKER_FLAG]: true }; -} - -/** - * 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 `\u0000` escape, 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. - */ -function logEntryKey(entry: AgentLogEntry): string { - return [entry.timestamp, entry.type, entry.text, entry.detail ?? "", entry.agent ?? ""].join("\u0000"); -} - -/** - * 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. */ -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-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. The unreconcilable older prefix is DROPPED and a gap -marker is put at the head rather than concatenated blindly, because keeping it would (a) imply a -continuity that does not exist and (b) break `loadMore`'s offset arithmetic, which requires the -buffer to stay a contiguous suffix of the server log. The dropped prefix is re-fetchable: the first -"load older" pulls the true entries below the gap and the marker is retired. - -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 marker is re-attached after the cap, so a trim can never silently swallow the very -signal that says entries are missing. -*/ -export function reconcileReconnectedEntries( - prev: AgentLogEntry[], - fresh: AgentLogEntry[], - pending: AgentLogEntry[], - taskId: string, -): { 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 { - merged = fresh; - gapMarker = createLogGapMarker(taskId, fresh[0]?.timestamp ?? new Date(0).toISOString()); - gapInserted = true; - } - } - - merged = appendWithoutDuplicates(merged, pending); - - 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, - }; -} +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; @@ -236,6 +114,35 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? // stable `prev` snapshot and cannot duplicate or drop lines that race the fetch. const resyncInFlightRef = useRef(false); const pendingLiveRef = useRef([]); + /* + 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. @@ -274,7 +181,10 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? // 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); @@ -321,23 +231,66 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? 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 || resyncInFlightRef.current || isStale()) return; + 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 | undefined; try { - const result = await fetchAgentLogsWithMeta(resyncTaskId, currentProjectId, { - limit: INITIAL_LOAD_LIMIT, - }); - if (isStale()) return; + const result = await Promise.race([ + fetchAgentLogsWithMeta(resyncTaskId, currentProjectId, { limit: INITIAL_LOAD_LIMIT }), + new Promise((_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); + const outcome = reconcileReconnectedEntries(prev, result.entries, pending, resyncTaskId, { + preservePagedHistory: true, + }); if (outcome.trimmed) trimmedLiveTailRef.current = true; return outcome.entries; }); @@ -347,15 +300,17 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? // 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 next reconnect retries the resync. + // Fall through: the live tail keeps running and the resync is retried below/on next reconnect. } finally { - resyncInFlightRef.current = false; - if (!reconciled && !isStale()) { - const pending = pendingLiveRef.current; - pendingLiveRef.current = []; - if (pending.length > 0) { - setEntries((prev) => appendWithoutDuplicates(prev, pending)); - } + 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(); } } } @@ -442,8 +397,19 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? resync sets the authoritative count. */ if (resyncInFlightRef.current) { - pendingLiveRef.current.push(entry); - return; + 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: @@ -459,10 +425,21 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? idempotent so React's double-invoked updaters cannot corrupt it. */ setEntries((prev) => { - const limit = Math.max(MAX_LOG_ENTRIES, prev.length); - if (prev.length + 1 <= limit) return [...prev, entry]; - trimmedLiveTailRef.current = true; - return [...prev.slice(prev.length + 1 - limit), entry]; + 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 { @@ -486,13 +463,18 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? }, [taskId, enabled, projectId]); /* - FNXC:AgentLogPaging 2026-07-26-15:22: - The reconnect gap marker is a client-only row and always sits at index 0, so one count keeps every - server-offset calculation honest. Derived as a number rather than from the array so `loadMore`'s - identity churns no more often than it did before the marker existed (once per buffer-length - change), not on every streamed line. + 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 gapMarkerCount = entries.length > 0 && isLogGapMarker(entries[0]) ? 1 : 0; + const newestContiguousCount = countNewestContiguousEntries(entries); + const gapPresent = hasLogGapMarker(entries); /** * Load more older entries. @@ -502,13 +484,15 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? if (!taskId || loadingMore) return; const contextVersionAtStart = projectContextVersionRef.current; + const baseBufferVersion = bufferVersionRef.current; /* - FNXC:AgentLogPaging 2026-07-26-14:38: - 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. + 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 = entries.length - gapMarkerCount; + const currentEntriesCount = newestContiguousCount; const currentTaskId = taskId; setLoadingMore(true); @@ -525,17 +509,27 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? } /* - FNXC:AgentLogPaging 2026-07-26-14:41: - Prepend older entries. Those entries are exactly the ones immediately below the buffer, so a - gap marker at the head is now closed by real data and must be retired; a `hasMore:false` - response also retires it because the server has proven nothing older exists. Leaving a stale - marker in place would claim a discontinuity that the page just filled. + 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. */ - const retireGapMarker = result.entries.length > 0 || !result.hasMore; - setEntries((prev) => { - const base = retireGapMarker ? prev.filter((entry) => !isLogGapMarker(entry)) : prev; - return [...result.entries, ...base]; - }); + 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 @@ -546,11 +540,12 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? } finally { setLoadingMore(false); } - }, [taskId, projectId, entries.length, gapMarkerCount, loadingMore]); + }, [taskId, projectId, newestContiguousCount, loadingMore]); const clear = useCallback(() => { trimmedLiveTailRef.current = false; pendingLiveRef.current = []; + bufferVersionRef.current++; setEntries([]); }, []); const initialContextLoading = Boolean(activeContextKey && loadedContextKey !== activeContextKey); @@ -561,13 +556,14 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId? clear, loadMore, /* - FNXC:AgentLogPaging 2026-07-26-14:44: - 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 paging response that proves the buffer reaches entry 0 - — `hasMore` is now true iff older entries actually remain. + 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 || gapMarkerCount > 0, + hasMore: hasMore || trimmedLiveTailRef.current || gapPresent, total, loadingMore, }; diff --git a/packages/dashboard/app/hooks/useApprovalBanner.ts b/packages/dashboard/app/hooks/useApprovalBanner.ts index e90157f6e7..3a45db4bce 100644 --- a/packages/dashboard/app/hooks/useApprovalBanner.ts +++ b/packages/dashboard/app/hooks/useApprovalBanner.ts @@ -20,6 +20,7 @@ import { parseDateMs, persistApprovalBannerDismissals, } from "../utils/appLifecycle"; +import { createResyncRetryRunner } from "./resyncRetry"; export interface UseApprovalBannerOptions { tasks: Task[]; @@ -32,6 +33,16 @@ export interface UseApprovalBannerOptions { export interface UseApprovalBannerResult { candidate: ApprovalBannerCandidate | null; dismissApproval: (candidate: ApprovalBannerCandidate) => void; + /* + FNXC:ApprovalBanner 2026-07-26-18:20: + True when every attempt of a reconnect resync ladder failed, so "no banner" no longer means "no + pending approval" — it means we could not find out. The banner host renders this as the + operator-visible signal that approvals may be missing; without it, a failed resync is + indistinguishable from a quiet system, which is exactly how a pending decision goes unanswered. + NOTE: this hook only PRODUCES the flag. As of this change no consumer renders it yet + (AppInner is outside this change's file scope) — the bounded retry below is the fix that is live. + */ + approvalsMayBeIncomplete: boolean; } export function useApprovalBanner({ @@ -41,6 +52,7 @@ export function useApprovalBanner({ onStarPrompt, }: UseApprovalBannerOptions): UseApprovalBannerResult { const [candidate, setCandidate] = useState(null); + const [approvalsMayBeIncomplete, setApprovalsMayBeIncomplete] = useState(false); const taskStatusByIdRef = useRef>(new Map()); const seenApprovalKeysRef = useRef>(new Set()); const approvalDismissalsRef = useRef>(loadApprovalBannerDismissals()); @@ -82,38 +94,53 @@ export function useApprovalBanner({ surface the newest undismissed pending request, and clear a banner whose request is no longer pending (decided elsewhere while we were disconnected). */ - const resyncPendingApprovals = () => { - void fetchApprovals({ status: "pending", limit: 50 }, currentProjectId) - .then((list) => { - if (disposed) return; - const pending = [...list.requests].sort((a, b) => - (b.updatedAt ?? b.createdAt ?? "").localeCompare(a.updatedAt ?? a.createdAt ?? ""), - ); - const newest = pending.find((request) => { - const dedupeKey = `approval:${request.id}`; - const dismissedAt = approvalDismissalsRef.current.get(dedupeKey); - return dismissedAt === undefined || parseDateMs(request.updatedAt ?? request.createdAt) > dismissedAt; - }); - if (!newest) { - // Server has nothing pending for us: any banner still on screen was decided while the - // stream was down. - setCandidate((current) => (current === null ? current : null)); - return; - } - const dedupeKey = `approval:${newest.id}`; - seenApprovalKeysRef.current.add(dedupeKey); - triggerApprovalBanner({ - dedupeKey, - updatedAtMs: parseDateMs(newest.updatedAt ?? newest.createdAt), - }); - }) - .catch(() => { - // A failed resync must not clear a banner we already have; the next reopen retries. - }); + /* + FNXC:ApprovalBanner 2026-07-26-18:24: + CORRECTION to the previous handler, whose catch said "the next reopen retries" and did nothing. + That reasoning was WRONG and must not be reintroduced: once the connection is healthy again there + may be no further reopen for hours, so a single failed refetch permanently hid a pending + approval — the operator saw no banner and the requesting agent blocked on a decision it never + got. Failures now run the shared bounded ladder (resyncRetry) and, if the whole ladder fails, + raise `approvalsMayBeIncomplete` instead of leaving silence that reads as "nothing pending". + Still true and preserved: a failed attempt never clears a banner already on screen. + */ + const resyncPendingApprovals = async () => { + const list = await fetchApprovals({ status: "pending", limit: 50 }, currentProjectId); + if (disposed) return; + const pending = [...list.requests].sort((a, b) => + (b.updatedAt ?? b.createdAt ?? "").localeCompare(a.updatedAt ?? a.createdAt ?? ""), + ); + const newest = pending.find((request) => { + const dedupeKey = `approval:${request.id}`; + const dismissedAt = approvalDismissalsRef.current.get(dedupeKey); + return dismissedAt === undefined || parseDateMs(request.updatedAt ?? request.createdAt) > dismissedAt; + }); + if (!newest) { + // Server has nothing pending for us: any banner still on screen was decided while the + // stream was down. + setCandidate((current) => (current === null ? current : null)); + return; + } + const dedupeKey = `approval:${newest.id}`; + seenApprovalKeysRef.current.add(dedupeKey); + triggerApprovalBanner({ + dedupeKey, + updatedAtMs: parseDateMs(newest.updatedAt ?? newest.createdAt), + }); }; + const approvalResync = createResyncRetryRunner({ + run: resyncPendingApprovals, + onExhausted: () => { + if (!disposed) setApprovalsMayBeIncomplete(true); + }, + onRecovered: () => { + if (!disposed) setApprovalsMayBeIncomplete(false); + }, + }); + const unsubscribe = subscribeSse(`/api/events${query}`, { - onReconnect: resyncPendingApprovals, + onReconnect: () => approvalResync.trigger(), events: { "approval:requested": (event: MessageEvent) => { try { @@ -156,6 +183,7 @@ export function useApprovalBanner({ return () => { disposed = true; + approvalResync.dispose(); unsubscribe(); }; }, [currentProjectId, gitHubStarPromptShown, onStarPrompt]); @@ -169,5 +197,5 @@ export function useApprovalBanner({ setCandidate(null); }, []); - return { candidate, dismissApproval }; + return { candidate, dismissApproval, approvalsMayBeIncomplete }; } diff --git a/packages/dashboard/app/hooks/useChat.ts b/packages/dashboard/app/hooks/useChat.ts index 292df361f1..d0bd6dbae7 100644 --- a/packages/dashboard/app/hooks/useChat.ts +++ b/packages/dashboard/app/hooks/useChat.ts @@ -19,6 +19,7 @@ import { type ChatStreamErrorMeta, } from "../api"; import { subscribeSse } from "../sse-bus"; +import { createResyncRetryRunner } from "./resyncRetry"; import { getScopedItem, setScopedItem, removeScopedItem } from "../utils/projectStorage"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import type { Agent, ChatInFlightGenerationState, ChatMessage, ChatTag } from "@fusion/core"; @@ -1840,47 +1841,82 @@ export function useChat( return () => clearInterval(interval); }, [attachIfGenerating, loadMessages, projectId, activeSession, flushPendingMessage]); + /* + FNXC:ChatStreaming 2026-07-26-18:55: + Authoritative reconciliation of the LOCAL stream ownership flag against the SERVER's generation + state, shared by the resume path and the SSE reconnect path. + + Why the server has to be asked: `streamRef.current` is cleared only by the stream's own + onDone/onError. iOS can tear the transport down during a 60s+ background suspend WITHOUT delivering + either callback (a hung reader, not a rejected promise), and a stale `streamRef` then means + "a stream owns the transcript" forever — every reconnect/resume handler that guards on it becomes a + permanent no-op, the transcript stays frozen mid-turn, and the reply never lands. A dead stream must + not be able to latch recovery off, and the only proof of death available to the client is the + server saying the session is no longer generating. + + Outcomes: + - server generating + no local stream -> (re)attach, as before. + - server generating + local stream -> the stream legitimately owns the transcript; leave it. + - server idle + local stream -> the stream is provably dead: close it, drop the streaming + state it can no longer clear, and reload the transcript. + - server idle + no local stream -> clear a stale "recovery mode" streaming state if any. + Rejects on fetch failure so callers can run the shared bounded retry ladder. + */ + const reconcileAttachedStream = useCallback(async () => { + const currentSession = activeSessionRef.current; + if (!currentSession) return; + + const contextVersionAtStart = projectContextVersionRef.current; + const data = await fetchChatSession(currentSession.id, projectId); + if ( + projectContextVersionRef.current !== contextVersionAtStart + || activeSessionRef.current?.id !== currentSession.id + ) { + return; + } + + const attachedStream = streamRef.current; + if (data.session.isGenerating) { + if (attachedStream) return; + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(true); + isStreamingRef.current = true; + attachIfGenerating(currentSession.id, data.session.inFlightGeneration, { silent: true }); + return; + } + + if (attachedStream) { + attachedStream.close(); + streamRef.current = null; + lastAttachedGenerationRef.current = null; + cancelStreamingFlushesRef.current?.(); + cancelStreamingFlushesRef.current = null; + } + + if (attachedStream || isStreamingRef.current) { + setStreamingText(""); + setStreamingThinking(""); + setStreamingToolCalls([]); + setIsStreaming(false); + isStreamingRef.current = false; + flushPendingMessage(); + void loadMessages(currentSession.id); + } + }, [attachIfGenerating, flushPendingMessage, loadMessages, projectId]); + useEffect(() => { + const resumeReconcile = createResyncRetryRunner({ run: reconcileAttachedStream }); const unsubscribe = visibilitySuspension.onBecameVisible(() => { - const currentSession = activeSessionRef.current; - if (!currentSession || streamRef.current) { - return; - } - - const contextVersionAtStart = projectContextVersionRef.current; - void fetchChatSession(currentSession.id, projectId) - .then((data) => { - if (projectContextVersionRef.current !== contextVersionAtStart || streamRef.current) { - return; - } - - if (data.session.isGenerating) { - setStreamingText(""); - setStreamingThinking(""); - setStreamingToolCalls([]); - setIsStreaming(true); - isStreamingRef.current = true; - attachIfGenerating(currentSession.id, data.session.inFlightGeneration, { silent: true }); - return; - } - - if (isStreamingRef.current) { - setStreamingText(""); - setStreamingThinking(""); - setStreamingToolCalls([]); - setIsStreaming(false); - isStreamingRef.current = false; - flushPendingMessage(); - void loadMessages(currentSession.id); - } - }) - .catch(() => { - // Intentionally silent for visibility reconnect path. - }); + resumeReconcile.trigger(); }); - return unsubscribe; - }, [attachIfGenerating, loadMessages, projectId, visibilitySuspension, flushPendingMessage]); + return () => { + resumeReconcile.dispose(); + unsubscribe(); + }; + }, [reconcileAttachedStream, visibilitySuspension]); // SSE real-time updates useEffect(() => { @@ -2027,17 +2063,35 @@ export function useChat( the visible thread with the server's. Skipped while a stream is attached because the streaming path owns the transcript and an authoritative reload mid-turn would fight it (see loadMessages' active-streaming guard). + + FNXC:ChatRealtime 2026-07-26-19:02: + CORRECTION to the guard above: `streamRef.current` being set used to mean "skip the reload", full + stop, and streamRef is cleared only by the stream's own terminal callbacks. A transport killed + during a suspend without a terminal callback therefore latched this resync OFF PERMANENTLY — + every later reconnect was a no-op against a frozen transcript. An attached stream is now + RECONCILED against the server's generation state (reconcileAttachedStream) instead of blindly + trusted; only a stream the server confirms is still generating keeps ownership of the transcript. + Failures run the shared bounded retry ladder rather than waiting for a reconnect that may not + come. `refreshSessions`/`loadMessages` swallow their own errors (they fall back to cache), so the + ladder covers exactly the authoritative session probe — the one call that can report failure. */ - const resyncChatState = () => { + const resyncChatState = async () => { + if (isStale()) return; + await refreshSessions(); if (isStale()) return; - void refreshSessions(); const currentSession = activeSessionRef.current; - if (!currentSession || streamRef.current) return; - void loadMessages(currentSession.id); + if (!currentSession) return; + if (streamRef.current) { + await reconcileAttachedStream(); + return; + } + await loadMessages(currentSession.id); }; + const chatResync = createResyncRetryRunner({ run: resyncChatState }); + const unsubscribe = subscribeSse(`/api/events${query}`, { - onReconnect: resyncChatState, + onReconnect: () => chatResync.trigger(), events: { "chat:session:created": handleChatSessionCreated, "chat:session:updated": handleChatSessionUpdated, @@ -2047,8 +2101,11 @@ export function useChat( }, }); - return unsubscribe; - }, [attachIfGenerating, getChatMessagesCacheKey, loadMessages, projectId, flushPendingMessage, refreshSessions]); + return () => { + chatResync.dispose(); + unsubscribe(); + }; + }, [attachIfGenerating, getChatMessagesCacheKey, loadMessages, projectId, flushPendingMessage, reconcileAttachedStream, refreshSessions]); // Cleanup on unmount useEffect(() => { diff --git a/packages/dashboard/app/hooks/useLiveTimeTicker.ts b/packages/dashboard/app/hooks/useLiveTimeTicker.ts index 440b821b70..caa8573879 100644 --- a/packages/dashboard/app/hooks/useLiveTimeTicker.ts +++ b/packages/dashboard/app/hooks/useLiveTimeTicker.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { createVisibilityGatedTimer } from "./visibilitySuspension"; /* FNXC:BoardPerformance 2026-07-26-09:40: @@ -61,15 +62,28 @@ function stopInterval(): void { intervalId = null; } +/* +FNXC:BoardPerformance 2026-07-26-16:05: +The stop/resume bookkeeping is delegated to `createVisibilityGatedTimer` in `visibilitySuspension.ts`, which +that module documents as the single visibility gate. This ticker previously hand-rolled a second copy of the +same shape. It still cannot call `useVisibilityAwarePoll` — the ticker is a module singleton fanning out to a +subscriber set, not a per-consumer hook — so the non-hook gate is what the two share. Requirement 3 (catch up +on return before re-arming) is the gate's `onResume`; `resumeDelayMs` is left at 0 because this timer issues +no network request, so it is not part of the visible-edge connection stampede the stagger exists to bound. + +Behavior note: the gate is a no-op when the interval is already armed, so a redundant `visibilitychange` +fired while the tab is already visible no longer produces an extra tick. The previous code notified on every +such event; that was wasted renders, not a correctness requirement. +*/ +const visibilityGate = createVisibilityGatedTimer({ + arm: startInterval, + disarm: stopInterval, + isArmed: () => intervalId !== null, + onResume: notifyListeners, +}); + function handleVisibilityChange(): void { - if (!isDocumentVisible()) { - stopInterval(); - return; - } - // Requirement 3: catch up on return before re-arming, so the first frame after - // the tab is foregrounded already shows a fresh elapsed time. - notifyListeners(); - startInterval(); + visibilityGate.handleVisibilityChange(); } /** @@ -90,7 +104,8 @@ export function subscribeLiveTimeTicker(listener: TickListener): () => void { if (listeners.size > 0) { return; } - stopInterval(); + // Via the gate so any pending resume is cancelled too, not just the interval. + visibilityGate.stop(); if (visibilityListenerBound && typeof document !== "undefined") { document.removeEventListener("visibilitychange", handleVisibilityChange); visibilityListenerBound = false; diff --git a/packages/dashboard/app/hooks/useLiveTranscript.ts b/packages/dashboard/app/hooks/useLiveTranscript.ts index 8447e97c1f..b0eccc4e8e 100644 --- a/packages/dashboard/app/hooks/useLiveTranscript.ts +++ b/packages/dashboard/app/hooks/useLiveTranscript.ts @@ -1,6 +1,9 @@ import { useState, useEffect, useRef } from "react"; +import type { AgentLogEntry } from "@fusion/core"; import { fetchAgentLogsWithMeta } from "../api"; import { subscribeSse } from "../sse-bus"; +import { appendWithoutDuplicates } from "./logStreamReconcile"; +import { createResyncRetryRunner } from "./resyncRetry"; // Render shows only the first 20 entries; keep a small buffer above that for // hot-reconnect/scrollback but never let the array grow unbounded — long-lived @@ -24,6 +27,22 @@ export interface TranscriptEntry { content?: string; } +/** A live/persisted log row in the shared oldest-first reconcile shape, keeping the legacy field. */ +type ReconcilableEntry = AgentLogEntry & { content?: string }; + +/** Oldest-first reconcile shape -> rendered newest-first, bounded transcript. */ +function toTranscriptEntries(entries: ReconcilableEntry[]): TranscriptEntry[] { + return entries + .slice(-MAX_TRANSCRIPT_ENTRIES) + .reverse() + .map((raw) => ({ + type: raw.type ?? "text", + text: raw.text ?? "", + timestamp: raw.timestamp || undefined, + ...(raw.content !== undefined ? { content: raw.content } : {}), + })); +} + /** * Hook that manages live transcript streaming for a task. * @@ -34,6 +53,9 @@ export interface TranscriptEntry { * * When `taskId` changes, a new SSE connection is opened for the new task. * When `projectId` changes, all state is reset and a new connection is opened. + * + * **Reconnect semantics**: the stream replays nothing on open, so every reconnect refetches the + * persisted tail and merges it with events that raced the fetch (see resyncTranscript). */ export function useLiveTranscript(taskId: string | undefined, projectId?: string) { const [entries, setEntries] = useState([]); @@ -41,6 +63,16 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string // Refs for state that needs to survive re-renders const unsubscribeRef = useRef<(() => void) | null>(null); + /* + FNXC:TaskTranscript 2026-07-26-18:40: + A reconnect refetch is in flight; live `agent:log` events are parked here instead of being + prepended. Ported from useAgentLogs: the refetched page is a snapshot from BEFORE those events, so + writing it over a buffer that moved meanwhile deleted the newest lines with no replay path (the + stream never resends). Parked entries are merged after the page, deduped by content, so an entry + present in both is rendered once. + */ + const resyncInFlightRef = useRef(false); + const pendingLiveRef = useRef([]); // Track the project context version to detect stale events after project switches. // Incremented whenever projectId changes, invalidating any in-flight SSE handlers. @@ -67,6 +99,8 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string } // Reset state immediately to prevent stale data visibility + resyncInFlightRef.current = false; + pendingLiveRef.current = []; setEntries([]); setIsConnected(false); } @@ -92,42 +126,73 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string Missed-event recovery. `/api/tasks/:id/logs/stream` replays nothing on connect — it only forwards entries emitted while the socket is live — so every SSE gap (error reconnect, or the mobile hidden-tab suspend) punched a permanent hole in the transcript, silently mixing entries from - before and after the gap with no marker. On reopen, replace the buffer with the persisted tail - from GET /tasks/:id/logs, which is the same data the stream would have delivered. The API returns - oldest-first; this hook renders newest-first. + before and after the gap with no marker. On reopen, converge on the persisted tail from + GET /tasks/:id/logs, which is the same data the stream would have delivered, merged with + anything that streamed in during the fetch. The API returns oldest-first; this hook renders + newest-first. */ - const resyncTranscript = () => { - void fetchAgentLogsWithMeta(taskId, projectId, { limit: MAX_TRANSCRIPT_ENTRIES }) - .then((result) => { - if (projectContextVersionRef.current !== contextVersionAtStart) return; - const normalized: TranscriptEntry[] = result.entries.map((raw) => ({ - type: raw.type ?? "text", - text: raw.text ?? "", - timestamp: raw.timestamp, - })); - setEntries(normalized.reverse().slice(0, MAX_TRANSCRIPT_ENTRIES)); - }) - .catch(() => { - // Keep the current buffer on failure; the next reopen retries. - }); + const isStale = () => projectContextVersionRef.current !== contextVersionAtStart; + + /* + FNXC:TaskTranscript 2026-07-26-18:44: + CORRECTION to the previous resync, which called setEntries(server page) unconditionally: entries + that arrived while the fetch was in flight were older than the state write and were DELETED, and + this stream replays nothing, so they were unrecoverable. The fetch window is now a parking window + (pendingLiveRef) and the parked entries are merged onto the page with the shared + `appendWithoutDuplicates`, the same de-dup useAgentLogs uses, so a line that is in both the page + and the live stream renders exactly once. + The old catch comment ("the next reopen retries") was ALSO wrong — a healthy connection may not + reopen again for hours. Failures go through the shared bounded ladder, and parked entries are + flushed rather than dropped when the page cannot be fetched. + */ + const resyncTranscript = async () => { + if (resyncInFlightRef.current || isStale()) return; + resyncInFlightRef.current = true; + pendingLiveRef.current = []; + let reconciled = false; + try { + const result = await fetchAgentLogsWithMeta(taskId, projectId, { limit: MAX_TRANSCRIPT_ENTRIES }); + if (isStale()) return; + const pending = pendingLiveRef.current; + pendingLiveRef.current = []; + reconciled = true; + setEntries(toTranscriptEntries(appendWithoutDuplicates(result.entries, pending) as ReconcilableEntry[])); + } finally { + resyncInFlightRef.current = false; + if (!reconciled && !isStale()) { + const pending = pendingLiveRef.current; + pendingLiveRef.current = []; + if (pending.length > 0) { + // Failed/stale refetch: a degraded live tail still beats silently dropping lines. + setEntries((prev) => [...toTranscriptEntries(pending), ...prev].slice(0, MAX_TRANSCRIPT_ENTRIES)); + } + } + } }; + const transcriptResync = createResyncRetryRunner({ run: resyncTranscript }); + const unsubscribe = subscribeSse(url, { - onReconnect: resyncTranscript, + onReconnect: () => transcriptResync.trigger(), events: { "agent:log": (event) => { - if (projectContextVersionRef.current !== contextVersionAtStart) return; + if (isStale()) return; try { const raw = JSON.parse(event.data) as Partial; // Normalize: canonical `text` field, with legacy `content` fallback - const entry: TranscriptEntry = { - type: raw.type ?? "text", + const entry: ReconcilableEntry = { + type: (raw.type ?? "text") as AgentLogEntry["type"], text: raw.text ?? raw.content ?? "", - timestamp: raw.timestamp, - content: raw.content, + timestamp: raw.timestamp ?? "", + taskId, + ...(raw.content !== undefined ? { content: raw.content } : {}), }; + if (resyncInFlightRef.current) { + pendingLiveRef.current.push(entry); + return; + } setEntries(prev => { - const next = [entry, ...prev]; + const next = [...toTranscriptEntries([entry]), ...prev]; return next.length > MAX_TRANSCRIPT_ENTRIES ? next.slice(0, MAX_TRANSCRIPT_ENTRIES) : next; @@ -149,6 +214,7 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string unsubscribeRef.current = unsubscribe; return () => { + transcriptResync.dispose(); unsubscribe(); unsubscribeRef.current = null; if (projectContextVersionRef.current === contextVersionAtStart) { diff --git a/packages/dashboard/app/hooks/useMeshState.ts b/packages/dashboard/app/hooks/useMeshState.ts index 06e779a2ea..b6b2f517db 100644 --- a/packages/dashboard/app/hooks/useMeshState.ts +++ b/packages/dashboard/app/hooks/useMeshState.ts @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { NodeMeshState } from "@fusion/core"; import { fetchMeshState } from "../api"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; -import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; +import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension"; const POLL_INTERVAL_MS = 10000; const VISIBILITY_REFRESH_DEBOUNCE_MS = 1000; @@ -20,7 +20,6 @@ export function useMeshState(): UseMeshStateResult { const [meshState, setMeshState] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); const meshStateRef = useRef(meshState); const visibilitySuspension = useTabVisibilitySuspension(); @@ -104,19 +103,17 @@ export function useMeshState(): UseMeshStateResult { }; }, [refresh, shouldSuppressVisibilityResumeError, t]); - useEffect(() => { - if (loading) return; - intervalRef.current = setInterval(() => { - void refresh(); - }, POLL_INTERVAL_MS); + /* + FNXC:MobileTabRetention 2026-07-26-16:05: + This 10s mesh-state poll was NOT visibility-gated: the `visibilitychange` listener above only ADDS a refresh + on the visible edge and never cleared the interval, so a backgrounded tab kept fetching mesh state every 10 + seconds. Background network work is the primary signal iOS Safari / iOS PWA / Chrome Android use to discard + a tab, producing the white-splash reload on return. - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [loading, refresh]); + `refreshOnVisible: false` because the debounced, instrumented listener above already owns the visible-edge + refresh; letting the helper refresh too would fetch twice on one edge. + */ + useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: !loading, refreshOnVisible: false }); return { meshState, loading, error, refresh }; } diff --git a/packages/dashboard/app/hooks/useMultiAgentLogs.ts b/packages/dashboard/app/hooks/useMultiAgentLogs.ts index 3a00eaf49e..95fc2d0046 100644 --- a/packages/dashboard/app/hooks/useMultiAgentLogs.ts +++ b/packages/dashboard/app/hooks/useMultiAgentLogs.ts @@ -2,24 +2,27 @@ 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"; -export const MAX_LOG_ENTRIES = 500; const INITIAL_LOAD_LIMIT = 100; -/** - * Cap the total number of log entries to `MAX_LOG_ENTRIES`. - * - * This is a **whole-list cap** — it limits how many entries are kept - * in memory, not the content of any individual entry. Tool-oriented - * `detail` payloads may still be clipped server-side to keep the live - * dashboard responsive when agents emit very large command results. - */ -function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] { - return entries.length > MAX_LOG_ENTRIES - ? entries.slice(-MAX_LOG_ENTRIES) - : entries; -} - export interface TaskLogState { entries: AgentLogEntry[]; loading: boolean; @@ -55,6 +58,11 @@ interface InitState { * * 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 @@ -66,6 +74,23 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta const cancelledRef = useRef>({}); const pendingLiveEntriesRef = useRef>({}); const loadingMoreRef = useRef>({}); + /* + 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>({}); + const resyncPendingRef = useRef>({}); + /* + 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>({}); // Track project context version to detect stale events after project switches. // Incremented whenever projectId changes, invalidating any in-flight SSE handlers. @@ -90,6 +115,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta cancelledRef.current = {}; pendingLiveEntriesRef.current = {}; loadingMoreRef.current = {}; + resyncInFlightRef.current = {}; + resyncPendingRef.current = {}; + trimmedLiveTailRef.current = {}; // Clear all state immediately to prevent stale data visibility setStateMap({}); @@ -102,6 +130,8 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta const current = prev[taskId]; if (!current) return prev; pendingLiveEntriesRef.current[taskId] = []; + resyncPendingRef.current[taskId] = []; + trimmedLiveTailRef.current[taskId] = false; return { ...prev, [taskId]: { ...current, entries: [] }, @@ -114,11 +144,18 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta const createLoadMoreFn = useCallback((taskId: string, currentEntries: AgentLogEntry[]) => { return async () => { if (loadingMoreRef.current[taskId]) return; - if (!projectContextVersionRef.current) 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]; @@ -129,7 +166,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta try { const result = await fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT, - offset: currentEntries.length, + offset, }); // Reject stale response @@ -138,16 +175,29 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta return; } - // Prepend older entries to the existing list + /* + 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 combined = [...current.entries, ...result.entries]; + const base = retireGapMarker + ? current.entries.filter((entry) => !isLogGapMarker(entry)) + : current.entries; return { ...prev, [taskId]: { ...current, - entries: capLogEntries(combined), + entries: [...result.entries, ...base], hasMore: result.hasMore, total: result.total, loadingMore: false, @@ -214,6 +264,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta 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); } } @@ -246,6 +299,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta initializing.delete(taskId); delete pendingLiveEntriesRef.current[taskId]; delete loadingMoreRef.current[taskId]; + delete resyncInFlightRef.current[taskId]; + delete resyncPendingRef.current[taskId]; + delete trimmedLiveTailRef.current[taskId]; } } @@ -257,52 +313,107 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta 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:AgentLogs 2026-07-26-14:56: + 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) dropped every entry emitted while the socket was - down and left a silent hole in the merged transcript. On reopen, re-read the persisted tail — - the same fetch the initial load performs — and replace this task's entries with it. + 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 (cancelled[taskId] || projectContextVersionRef.current !== contextVersionAtStart) return; + 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 (cancelled[taskId] || projectContextVersionRef.current !== contextVersionAtStart) return; - pendingLiveEntriesRef.current[taskId] = []; + 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: capLogEntries(result.entries), + 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 (cancelled[taskId] || - projectContextVersionRef.current !== contextVersionAtStart) { - return; - } + 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, @@ -311,11 +422,14 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta 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: capLogEntries([...current.entries, entry]), + entries: appended.entries, total: current.total !== null ? current.total + 1 : null, }, }; @@ -332,12 +446,10 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta void fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT }) .then((result) => { // Reject stale response from previous context - if (cancelled[taskId] || - projectContextVersionRef.current !== contextVersionAtStart) { - return; - } + if (isStale()) return; const pendingLive = pendingLiveEntriesRef.current[taskId] ?? []; + if (!result.hasMore) trimmedLiveTailRef.current[taskId] = false; setStateMap((prev) => ({ ...prev, [taskId]: { @@ -351,10 +463,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta }) .catch(() => { // Reject stale error from previous context - if (cancelled[taskId] || - projectContextVersionRef.current !== contextVersionAtStart) { - return; - } + if (isStale()) return; const pendingLive = pendingLiveEntriesRef.current[taskId] ?? []; setStateMap((prev) => ({ @@ -412,6 +521,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta cancelledRef.current = {}; pendingLiveEntriesRef.current = {}; loadingMoreRef.current = {}; + resyncInFlightRef.current = {}; + resyncPendingRef.current = {}; + trimmedLiveTailRef.current = {}; }; }, []); @@ -424,7 +536,17 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta entries, loading: state?.loading ?? true, loadingMore: state?.loadingMore ?? false, - hasMore: state?.hasMore ?? 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), diff --git a/packages/dashboard/app/hooks/useNodes.ts b/packages/dashboard/app/hooks/useNodes.ts index a60ecfa37e..5af9d17037 100644 --- a/packages/dashboard/app/hooks/useNodes.ts +++ b/packages/dashboard/app/hooks/useNodes.ts @@ -14,7 +14,7 @@ import { } from "../api"; import { persistNodeProjectPathMappings } from "../api-node"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; -import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; +import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension"; export interface UseNodesResult { nodes: NodeInfo[]; @@ -44,7 +44,6 @@ export function useNodes(): UseNodesResult { const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); const nodesRef = useRef(nodes); const visibilitySuspension = useTabVisibilitySuspension(); @@ -133,20 +132,17 @@ export function useNodes(): UseNodesResult { }; }, [refresh, shouldSuppressVisibilityResumeError, t]); - useEffect(() => { - if (loading) return; + /* + FNXC:MobileTabRetention 2026-07-26-16:05: + This 10s node-registry poll was NOT visibility-gated: the `visibilitychange` listener above only ADDS a + refresh on the visible edge and never cleared the interval, so a backgrounded tab kept fetching the node + list every 10 seconds. Background network work is the primary signal iOS Safari / iOS PWA / Chrome Android + use to discard a tab, producing the white-splash reload on return. - intervalRef.current = setInterval(() => { - void refresh(); - }, POLL_INTERVAL_MS); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [loading, refresh]); + `refreshOnVisible: false` because the debounced, instrumented listener above already owns the visible-edge + refresh; letting the helper refresh too would fetch twice on one edge. + */ + useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: !loading, refreshOnVisible: false }); const register = useCallback(async (input: NodeOnboardingInput): Promise => { const { projectMappings, ...nodeInput } = input; diff --git a/packages/dashboard/app/hooks/useProjects.ts b/packages/dashboard/app/hooks/useProjects.ts index 416a446e03..d3651db4fd 100644 --- a/packages/dashboard/app/hooks/useProjects.ts +++ b/packages/dashboard/app/hooks/useProjects.ts @@ -12,7 +12,7 @@ import { } from "../api"; import { SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, clearCache, readCache, writeCache } from "../utils/swrCache"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; -import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension"; +import { isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension"; export interface UseProjectsResult { /** List of all registered projects (local + remote) */ @@ -90,7 +90,6 @@ export function useProjects(): UseProjectsResult { }); const [loading, setLoading] = useState(() => projects.length === 0); const [error, setError] = useState(null); - const intervalRef = useRef(null); const lastVisibilityRefreshRef = useRef(0); const projectsRef = useRef(projects); const visibilitySuspension = useTabVisibilitySuspension(); @@ -192,22 +191,19 @@ export function useProjects(): UseProjectsResult { }; }, [projects.length, refresh, shouldSuppressVisibilityResumeError]); - // Polling for updates - useEffect(() => { - // Only start polling after initial load completes - if (loading) return; + /* + FNXC:MobileTabRetention 2026-07-26-16:05: + This 5s poll was NOT visibility-gated, despite the mobile tab-retention work claiming every polling loop + was. The `visibilitychange` listener above only ADDS a refresh on the visible edge; it never cleared the + interval, so a backgrounded tab kept issuing a project-list fetch every 5 seconds. `useProjects` is mounted + unconditionally for the whole session (App.tsx), so this single loop was enough to keep the page permanently + non-idle — the exact signal iOS Safari / iOS PWA / Chrome Android use to discard the tab and force the + white-splash reload the whole effort was fixing. - intervalRef.current = setInterval(() => { - refresh(); - }, POLL_INTERVAL_MS); - - return () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - }, [loading, refresh]); + `refreshOnVisible: false` because the debounced, instrumented listener above already owns the visible-edge + refresh; letting the helper refresh too would fetch twice on one edge. + */ + useVisibilityAwarePoll(refresh, POLL_INTERVAL_MS, { enabled: !loading, refreshOnVisible: false }); const register = useCallback(async (input: ProjectCreateInput): Promise => { const project = await registerProject(input); diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index e2f6a8fc05..1e2a82fb79 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -6,6 +6,7 @@ import { subscribeSse } from "../sse-bus"; import { clearCache, readCache, readCacheSavedAt, SWR_CACHE_KEYS, SWR_TASKS_MAX_AGE_MS, writeCache } from "../utils/swrCache"; import { pushTrace } from "../utils/dashboardTraceBuffer"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { isLikelyTabSuspensionError } from "./visibilitySuspension"; const loggedTaskCacheHitProjects = new Set(); /* @@ -18,6 +19,31 @@ disabled off task-list views and missed events need server confirmation. */ const TASK_VIEW_REENTRY_FRESHNESS_MS = 60_000; +/* +FNXC:MobileTabDiscard 2026-07-26-16:40: +Destroying the task snapshot is only justified when the server actually answered and disagreed with it +(non-2xx, or a response that would not parse). A transport-level rejection on a waking mobile radio — +"Load failed" / "Failed to fetch" / an offline navigator — proves nothing about the snapshot's contents. + +Before this guard, the mount revalidation's `clearOnError` catch ran on those rejections and both blanked +the hydrated board and deleted the cache entry, so the NEXT restore also started empty: a suspension +failure permanently defeated the feature the raised `SWR_TASKS_MAX_AGE_MS` exists to serve. Nine sibling +hooks (useProjects, useNodes, useMeshState, useExecutorStats, useUsageData, useMeshEngines, +useManagedDockerNodes, useProjectHealth) already guard this with the same `visibilitySuspension` +predicate; this reuses it rather than adding a tenth copy. + +`navigator.onLine === false` is authoritative for "no request left the device"; `true` is not evidence of +reachability, so the message check still has to run. `lastRefreshErrorAt` is set either way, so the +re-entry/visibility retry paths still fire. +*/ +function didFailureReachServer(error: unknown): boolean { + if (typeof navigator !== "undefined" && navigator.onLine === false) { + return false; + } + const message = error instanceof Error ? error.message : String(error ?? ""); + return !isLikelyTabSuspensionError(message); +} + /* FNXC:MobileTabDiscard 2026-07-26-10:40: Snapshot-write budget. `writeCache` drops any payload over `maxBytes`, and it dropped SILENTLY: a board @@ -213,8 +239,14 @@ export function useTasks(options?: UseTasksOptions) { initializer is the only thing standing between the restore and a blank board, so it hydrates from a snapshot that may be hours old (`SWR_TASKS_MAX_AGE_MS`). That is safe only because hydration is always paired with revalidation: `isStale` starts true (App renders off it) - and the mount effect below unconditionally issues one `refreshTasks({ clearOnError: true })`, whose - failure branch CLEARS this cache entry so a wrong snapshot cannot survive into the next restore. + and the mount effect below unconditionally issues one `refreshTasks({ clearOnError: true })`. + + FNXC:MobileTabDiscard 2026-07-26-16:40: + CORRECTION to the sentence this note used to end with ("whose failure branch CLEARS this cache entry + so a wrong snapshot cannot survive into the next restore"): that is true only of a failure that + REACHED THE SERVER. A transport-level rejection (suspended tab, offline radio) now leaves the entry + and the painted rows alone, because it is not evidence the snapshot is wrong — and deleting on it made + every flaky-radio restore blank, permanently. See the catch branch of `refreshTasks`. */ /* FNXC:MobileTabDiscard 2026-07-26-14:12: @@ -282,6 +314,38 @@ export function useTasks(options?: UseTasksOptions) { paint honest; `refreshTasks` overwrites it with `Date.now()` the moment real server data lands. */ const lastFetchTimeMs = useRef(hydratedSnapshotSavedAtMs); + /* + FNXC:MobileTabDiscard 2026-07-26-16:40: + True only after a successful full-board fetch has confirmed EVERY row currently in `tasks`. A hydrated + snapshot does not confirm anything, so this starts false on mount and is reset to false whenever a + project switch repaints from cache. + + It gates the live-update writers of `lastFetchTimeMs` below. Those writers (task:created / task:moved / + task:updated, plus `ingestCreatedTasks`) each learn about ONE row, but `lastFetchTimeMs` is read as the + as-of time of ALL rows. While a hydrated hours-old board is still waiting for the mount revalidation on + a waking radio, a single unrelated task:created event used to stamp the whole board as measured-from- + now — re-creating, through a sibling path, the exact "every in-progress card renders stuck" regression + the savedAt seeding was added to fix. + + Once a fetch HAS confirmed the board, advancing on a live event stays honest and is deliberately kept: + the stream reports every task mutation in the project, so a row that produced no event really has not + changed and `now - updatedAt` is its true idle time. Freezing the clock at the last fetch instead would + make stuck detection silently stop firing for the rest of a long SSE session (there is no periodic + poll in this hook) — a false negative traded for the false positive. + */ + const boardFetchConfirmedRef = useRef(false); + /* + FNXC:MobileTabDiscard 2026-07-26-16:40: + Single seam for the live-update writers of the freshness clock; see `boardFetchConfirmedRef`. Kept as + one function so a future fifth writer cannot reintroduce the ungated `lastFetchTimeMs.current = + Date.now()` pattern by copy-paste. + */ + const advanceFreshnessClockForLiveUpdate = useCallback(() => { + if (!boardFetchConfirmedRef.current) { + return; + } + lastFetchTimeMs.current = Date.now(); + }, []); const lastConfirmedProjectIdRef = useRef(undefined); const lastConfirmedSearchQueryRef = useRef(undefined); const lastConfirmedIncludeArchivedRef = useRef(false); @@ -382,10 +446,13 @@ export function useTasks(options?: UseTasksOptions) { setLastRefreshErrorAt(null); // Record when we received fresh server data for stuck detection lastFetchTimeMs.current = Date.now(); + // FNXC:MobileTabDiscard 2026-07-26-16:40: every row in `tasks` is now server-confirmed, so live + // single-row updates may advance the clock from here on. + boardFetchConfirmedRef.current = true; lastConfirmedProjectIdRef.current = requestProjectId; lastConfirmedSearchQueryRef.current = query; lastConfirmedIncludeArchivedRef.current = wantArchived; - } catch { + } catch (error) { // Reject if project changed or version is stale if (fetchVersionRef.current !== requestVersion || projectId !== requestProjectId) { return; @@ -396,7 +463,21 @@ export function useTasks(options?: UseTasksOptions) { Load-bearing for the long hydration TTL: a snapshot is only allowed to outlive a tab discard because a failed revalidation deletes it here. Without this, an unverifiable board could be re-hydrated on every subsequent restore for the whole TTL window. Do not weaken. + + FNXC:MobileTabDiscard 2026-07-26-16:40: + CORRECTION — the 10:52 note above claimed "a failed revalidation deletes it", and the code did + exactly that for EVERY failure. That was wrong, and it broke the case the cache exists for: the + mount revalidation runs on a just-woken mobile radio, where the first fetch routinely rejects at + the transport layer. Such a rejection carries no information about the snapshot, yet it deleted + the entry AND (with `clearOnError`) blanked the freshly hydrated board — so the restore went white + and the next restore had nothing left to hydrate. Only a failure that actually reached the server + is allowed to destroy the snapshot; see `didFailureReachServer`. Suspension/offline failures leave + both the cache and the on-screen rows intact and rely on the retry paths keyed off + `lastRefreshErrorAt`. */ + if (!didFailureReachServer(error)) { + return; + } if (requestProjectId) { clearCache(`${SWR_CACHE_KEYS.TASKS_PREFIX}${requestProjectId}`); } @@ -559,6 +640,9 @@ export function useTasks(options?: UseTasksOptions) { before the mount/refresh effect below, so the fetch that resolves next still wins. */ lastFetchTimeMs.current = readCacheSavedAt(cacheKey, { maxAgeMs: SWR_TASKS_MAX_AGE_MS }); + // FNXC:MobileTabDiscard 2026-07-26-16:40: these rows come from cache, not the server — an SSE + // event must not stamp them as measured-from-now until this project's fetch confirms them. + boardFetchConfirmedRef.current = false; } setIsStale(true); }, [projectId]); @@ -695,7 +779,7 @@ export function useTasks(options?: UseTasksOptions) { next[existingIndex] = merged; return next; }); - lastFetchTimeMs.current = Date.now(); + advanceFreshnessClockForLiveUpdate(); }; const handleMoved = (e: MessageEvent) => { @@ -730,7 +814,7 @@ export function useTasks(options?: UseTasksOptions) { next[existingIndex] = movedTask; return next; }); - lastFetchTimeMs.current = Date.now(); + advanceFreshnessClockForLiveUpdate(); }; const handleUpdated = (e: MessageEvent) => { @@ -761,7 +845,7 @@ export function useTasks(options?: UseTasksOptions) { next[existingIndex] = merged; return next; }); - lastFetchTimeMs.current = Date.now(); + advanceFreshnessClockForLiveUpdate(); }; const handleDeleted = (e: MessageEvent) => { @@ -1205,8 +1289,8 @@ export function useTasks(options?: UseTasksOptions) { return next; }); - lastFetchTimeMs.current = Date.now(); - }, []); + advanceFreshnessClockForLiveUpdate(); + }, [advanceFreshnessClockForLiveUpdate]); return { tasks, isStale, lastRefreshErrorAt, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, bypassReview, resetTask, duplicateTask, updateTask, archiveTask, unarchiveTask, revertTask, archiveAllDone, loadArchivedTasks, loadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, includeArchived, refreshTasks, ingestCreatedTasks, lastFetchTimeMs: lastFetchTimeMs.current }; } diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts index c261d9dfd9..18d2e47a70 100644 --- a/packages/dashboard/app/hooks/useViewState.ts +++ b/packages/dashboard/app/hooks/useViewState.ts @@ -119,7 +119,14 @@ function getSessionStorage(): Storage | null { } } +/* +FNXC:ViewState 2026-07-26-19:22: +Symmetric with the writer: with no project there is no project-scoped key to read, and reading the +bare one would restore the previous project's view (including any bare key a pre-fix build left in +this tab's sessionStorage). Absent a project, the same-tab restore simply does not apply. +*/ function getScopedSessionTaskView(projectId?: string): string | null { + if (typeof projectId !== "string" || projectId.length === 0) return null; const storage = getSessionStorage(); if (!storage) return null; try { @@ -129,7 +136,17 @@ function getScopedSessionTaskView(projectId?: string): string | null { } } +/* +FNXC:ViewState 2026-07-26-19:15: +Project-scoped ONLY, enforced here rather than merely documented at the call site. +`scopedKey(base, undefined)` returns the BARE key, and the persist effect runs during boot and the +project-switch window when `currentProject` is undefined — so the previous version DID write the +unscoped mirror the adjacent comment claimed was never written, and the initializer then read it for +first paint, leaking the previous project's view into the next project's landing. Dropping the write +costs nothing: the value is re-persisted the moment a project resolves. +*/ function setScopedSessionTaskView(value: TaskView, projectId?: string): void { + if (typeof projectId !== "string" || projectId.length === 0) return; const storage = getSessionStorage(); if (!storage) return; try { @@ -201,10 +218,14 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult { }); const [taskView, setTaskView] = useState(() => { - // Same-tab restore (reload / OS tab discard) keeps the view the operator was actually on. - const sessionView = getScopedSessionTaskView(); - if (isTaskView(sessionView)) return resolveSessionTaskView(sessionView); - + /* + FNXC:ViewState 2026-07-26-19:18: + No unscoped session read here. The initializer runs before the project is known, so the only key + it could read is the bare one — which is precisely the cross-project leak the session copy is not + allowed to have (and which nothing writes any more). The same-tab restore therefore happens in + the project-hydration effect below, where the project id exists; first paint until then falls + back to the persisted/landing view exactly as it did before the session copy existed. + */ const saved = getScopedItem("kb-dashboard-task-view"); const legacyReliabilityView = migrateLegacyReliabilityView(saved); if (legacyReliabilityView) return legacyReliabilityView; @@ -266,8 +287,10 @@ export function useViewState(options: UseViewStateOptions): UseViewStateResult { setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id); /* Per-tab copy: what THIS tab is showing right now, for a reload/discard-restore of this tab. - Written with the same project scoping as the localStorage copy and never mirrored unscoped — - an unscoped mirror would let the previous project's view leak into the next project's landing. + Project-scoped, and skipped entirely while the project is unknown (boot and project-switch + windows) — an unscoped mirror would let the previous project's view leak into the next project's + landing. That skip is enforced inside setScopedSessionTaskView, not assumed here: this effect + deliberately still runs with `currentProject?.id === undefined`. */ setScopedSessionTaskView(taskView, currentProject?.id); }, [currentProject?.id, taskView]); diff --git a/packages/dashboard/app/hooks/visibilitySuspension.ts b/packages/dashboard/app/hooks/visibilitySuspension.ts index 3993b82d1d..81695aae86 100644 --- a/packages/dashboard/app/hooks/visibilitySuspension.ts +++ b/packages/dashboard/app/hooks/visibilitySuspension.ts @@ -44,13 +44,32 @@ count; user-visible staleness on return is therefore capped at ~3s for ancillary Board task data is NOT affected: `useTasks` owns its own `visibilitychange` refresh listener and never routes through this helper, so the operator's primary data is still refreshed immediately on return. */ -const VISIBLE_EDGE_STAGGER_STEP_MS = 150; -const VISIBLE_EDGE_STAGGER_WINDOW_MS = 3_000; -const VISIBLE_EDGE_STAGGER_SLOTS = Math.max( +/* +FNXC:MobileTabRetention 2026-07-26-16:05: +The stagger geometry is EXPORTED because the visible edge has more than one fan-out on it: the poll path here +and the SSE bus's channel reopen (`app/api/sse-bus.ts`) both burst on the same `visibilitychange`, against the +same 6-connection-per-origin cap. A second hand-rolled stagger would spread each fan-out independently while +leaving their union unbounded, so both paths must derive delays from this one slot function. +`visibleEdgeStaggerDelayMs` is pure (index -> delay) and owns no registry, so a caller with its own ordering +(SSE channels) and a caller with the module registry below (pollers) can share it without sharing state. +*/ +export const VISIBLE_EDGE_STAGGER_STEP_MS = 150; +export const VISIBLE_EDGE_STAGGER_WINDOW_MS = 3_000; +export const VISIBLE_EDGE_STAGGER_SLOTS = Math.max( 1, Math.round(VISIBLE_EDGE_STAGGER_WINDOW_MS / VISIBLE_EDGE_STAGGER_STEP_MS), ); +/** + * Deterministic (no randomness, therefore testable) delay for the Nth participant in a visible-edge fan-out. + * Modulo wrap keeps the spread bounded by {@link VISIBLE_EDGE_STAGGER_WINDOW_MS} regardless of participant + * count; index 0 is always synchronous so a lone participant is unchanged. + */ +export function visibleEdgeStaggerDelayMs(index: number): number { + const normalized = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0; + return (normalized % VISIBLE_EDGE_STAGGER_SLOTS) * VISIBLE_EDGE_STAGGER_STEP_MS; +} + /** Insertion-ordered registry of background subscribers; membership position is the stagger slot. */ const staggeredVisibleEdgeSubscribers = new Set(); @@ -63,7 +82,7 @@ function visibleEdgeDelayMs(token: object): number { } index += 1; } - return (index % VISIBLE_EDGE_STAGGER_SLOTS) * VISIBLE_EDGE_STAGGER_STEP_MS; + return visibleEdgeStaggerDelayMs(index); } /** @@ -75,6 +94,78 @@ export function __resetVisibleEdgeStaggerRegistryForTests(): void { staggeredVisibleEdgeSubscribers.clear(); } +/* +FNXC:MobileTabRetention 2026-07-26-16:05: +`createVisibilityGatedTimer` is the ONE implementation of "tear the timer down when hidden; fire once and +re-arm when visible". It exists because `useLiveTimeTicker` could not call `useVisibilityAwarePoll` — the +ticker is a module singleton with its own subscriber set, not a per-consumer hook — and so had independently +reimplemented the same start/stop/resume-on-visible shape, contradicting this module's own "do not add a +second visibility pattern" rule. Factoring the bookkeeping into a plain (non-hook) function lets both call +sites share it while keeping their different ownership models. + +Contract: +- `disarm()` runs on the hidden edge AND cancels any pending staggered resume, so a page that is re-hidden + mid-stagger issues no work at all — "a hidden page does no work" is preserved, not merely approximated. +- The visible edge is a no-op when something is already armed or a resume is already pending, so a duplicate + `visibilitychange` cannot produce a second refresh. +- `arm()` is invoked FROM the staggered instant (not from the edge), so participants stay drifted apart + instead of re-synchronizing one interval later. +*/ +export interface VisibilityGatedTimer { + /** Attach as the `visibilitychange` handler. */ + handleVisibilityChange: () => void; + /** Cancel a pending staggered resume and disarm. Safe to call from teardown. */ + stop: () => void; +} + +export function createVisibilityGatedTimer(spec: { + /** Arm the underlying timer. Must be idempotent. */ + arm: () => void; + /** Tear the underlying timer down. Must be idempotent. */ + disarm: () => void; + /** Whether the underlying timer is currently armed. */ + isArmed: () => boolean; + /** Fired once on the hidden -> visible edge, before `arm()`. */ + onResume?: () => void; + /** Delay applied to the resume, from the shared stagger geometry. Defaults to 0 (synchronous). */ + resumeDelayMs?: () => number; +}): VisibilityGatedTimer { + let pendingResume: ReturnType | null = null; + + const stop = () => { + if (pendingResume !== null) { + clearTimeout(pendingResume); + pendingResume = null; + } + spec.disarm(); + }; + + const resume = () => { + pendingResume = null; + spec.onResume?.(); + spec.arm(); + }; + + const handleVisibilityChange = () => { + // Non-DOM environments are treated as visible, matching every other visibility check in this module. + if (typeof document !== "undefined" && document.visibilityState === "hidden") { + stop(); + return; + } + if (spec.isArmed() || pendingResume !== null) { + return; + } + const delayMs = spec.resumeDelayMs?.() ?? 0; + if (delayMs === 0) { + resume(); + return; + } + pendingResume = setTimeout(resume, delayMs); + }; + + return { handleVisibilityChange, stop }; +} + export type VisibilityPollPriority = "critical" | "background"; export function useVisibilityAwarePoll( @@ -110,58 +201,38 @@ export function useVisibilityAwarePoll( } let timer: ReturnType | null = null; - let staggerTimer: ReturnType | null = null; const start = () => { if (timer === null) { timer = setInterval(tick, intervalMs); } }; - const stop = () => { - if (staggerTimer !== null) { - clearTimeout(staggerTimer); - staggerTimer = null; - } - if (timer !== null) { - clearInterval(timer); - timer = null; - } - }; - // Refresh once and re-arm the interval FROM the staggered instant, so subscribers stay drifted apart - // instead of re-synchronizing one interval after the edge. - const resume = () => { - staggerTimer = null; - if (refreshOnVisible) { - tick(); - } - start(); - }; - - const handleVisibilityChange = () => { - if (document.visibilityState === "hidden") { - stop(); - return; - } - // Both timers null is the hidden -> visible edge (or a mount that happened while hidden). The - // `staggerTimer` half of the guard also makes a duplicate visibilitychange during the stagger window - // a no-op rather than a second queued refresh. - if (timer === null && staggerTimer === null) { - const delayMs = priority === "critical" ? 0 : visibleEdgeDelayMs(staggerToken); - if (delayMs === 0) { - resume(); - } else { - staggerTimer = setTimeout(resume, delayMs); + // Bookkeeping (stop-on-hidden, refresh-once-then-re-arm-on-visible, duplicate-edge guard, cancel a + // pending stagger when re-hidden) lives in the shared gate so `useLiveTimeTicker` runs the same logic. + const gate = createVisibilityGatedTimer({ + arm: start, + disarm: () => { + if (timer !== null) { + clearInterval(timer); + timer = null; } - } - }; + }, + isArmed: () => timer !== null, + onResume: () => { + if (refreshOnVisible) { + tick(); + } + }, + resumeDelayMs: () => (priority === "critical" ? 0 : visibleEdgeDelayMs(staggerToken)), + }); if (document.visibilityState !== "hidden") { start(); } - document.addEventListener("visibilitychange", handleVisibilityChange); + document.addEventListener("visibilitychange", gate.handleVisibilityChange); return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - stop(); + document.removeEventListener("visibilitychange", gate.handleVisibilityChange); + gate.stop(); staggeredVisibleEdgeSubscribers.delete(staggerToken); }; }, [enabled, intervalMs, refreshOnVisible, priority]); diff --git a/packages/dashboard/app/public/sw.js b/packages/dashboard/app/public/sw.js index da184f9aa5..e3e124bd58 100644 --- a/packages/dashboard/app/public/sw.js +++ b/packages/dashboard/app/public/sw.js @@ -1,4 +1,13 @@ -const CACHE_NAME = "fusion-cache-v6"; +/* +FNXC:PWAOffline 2026-07-26-18:05: +Bumped v6 -> v7 to EVACUATE already-persisted credentials. Until this change every successful GET /api/* +response was written to Cache Storage, including GET /api/settings and /api/settings/global, whose bodies +carry plaintext `daemonToken`, `githubAuthToken`, `gitlabAuthToken`, and `ntfyAccessToken`. Adding the +allow-list below stops NEW writes but cannot reach entries an installed worker already wrote; `activate` +deletes every cache whose key !== CACHE_NAME, so the rename is the only mechanism that removes them from +durable origin storage on an existing install. Any future change to what may be cached must bump this too. +*/ +const CACHE_NAME = "fusion-cache-v7"; /* FNXC:PWAOffline 2026-07-26-10:12: @@ -16,7 +25,12 @@ const HASHED_ASSET_PATTERN = /-(?=[A-Za-z0-9_-]*[A-Z0-9])[A-Za-z0-9_]{7,}[A-Za-z * 1. Content-hashed build assets under /assets/ — immutable by construction (see HASHED_ASSET_PATTERN). * 2. Font requests (`request.destination === "font"`, e.g. the preloaded /fonts/SymbolsNerdFontMono-Regular.ttf) — not hash-named, but immutable in practice; a replaced font is picked up on the next CACHE_NAME bump. Blocking first paint on a font refetch over a waking radio is not worth that staleness window. * - * Deliberately NOT admissible: the navigation shell, /api/*, and non-hashed scripts/styles. + * Deliberately NOT admissible to the CACHE-FIRST path: the navigation shell, /api/*, and non-hashed + * scripts/styles. (Corrected 2026-07-26-18:05: the previous wording — "Deliberately NOT admissible" — + * read as a blanket exclusion from the cache. It never was one: those classes are merely network-FIRST + * and were still written to the same cache as an offline fallback. That misreading is how credential- + * bearing /api/ responses sat in durable storage unnoticed. What may be WRITTEN for /api/ is decided + * solely by isCacheableApiUrl() below, not by this predicate.) * * @param {URL} url * @param {Request} request @@ -38,7 +52,14 @@ Chosen bound: an insertion-ordered entry cap over hashed /assets/ entries only. - Why not a CACHE_NAME-per-build bump: correct in principle, but CACHE_NAME is a literal in a static, untemplated file. Doing it properly needs build-time templating of sw.js (a Vite plugin emitting the build hash) — real build wiring, not a one-line change, and out of scope here. Recorded as the eventual better answer rather than faked with a hardcoded hash. - Why insertion order is the right recency proxy: hashed URLs are immutable, so an entry is only ever inserted once, at the moment its build first ran. Cache API `keys()` is specified to return entries in insertion order, so oldest-first == oldest-build-first. Evicting from the front removes dead builds before live ones, with no metadata bookkeeping to persist or corrupt. -Safety against evicting an asset the RUNNING build still needs: every hashed URL this service-worker session has served (hit or miss) is recorded in `sessionReferencedAssets` and is exempt from eviction while it remains in that set (itself bounded — see MAX_SESSION_REFERENCED_ASSETS). A chunk the current page has already loaded is thereby pinned. A not-yet-lazy-loaded chunk of the current build is protected by the cap being sized for multiple builds, and by the fact that it is at the END of insertion order. `versionCheck.ts`'s handleChunkLoadError/isStaleChunkError remains a backstop, deliberately not the primary design. +Partial safety against evicting an asset the RUNNING build still needs: every hashed URL this service-worker SESSION has served (hit or miss) is recorded in `sessionReferencedAssets` and is exempt from eviction while it remains in that set (itself bounded — see MAX_SESSION_REFERENCED_ASSETS). + +CORRECTION 2026-07-26-18:05 — the previous wording ("A chunk the current page has already loaded is thereby pinned") asserted a guarantee this code does not deliver, and the claim must not be reintroduced. `sessionReferencedAssets` is an in-memory Set in the worker's global scope, and browsers idle-terminate a service worker after ~30s of no events — the normal state of a BACKGROUNDED tab, i.e. exactly the scenario this feature exists for. The worker that wakes to serve the restored tab is a cold start with an EMPTY set, so on the first prune after every wake NOTHING is pinned. + +What actually bounds the residual risk, and why it was left as-is rather than papered over: +- Eviction is oldest-INSERTED-first, and the running build's chunks are the most recently inserted, so the natural ordering already protects them in the common case. The pin set only adds protection in the uncommon inversion where a live build's chunks are older than some other build's — e.g. a long-lived tab on build N while another tab loaded build N+1. +- Durably persisting the set would re-pin a PREVIOUS build's chunks on every restart with no way to tell which build is now live, converting a bounded eviction risk into an unbounded pinning one. Not worth it for the inversion case. +- The failure mode is a re-fetch, not a break: an evicted chunk still on the server is fetched again. The only unrecoverable case (chunk gone from the server) is the stale-chunk case `versionCheck.ts`'s handleChunkLoadError/isStaleChunkError already handles, and it would occur with no cache at all. */ const MAX_IMMUTABLE_CACHE_ENTRIES = 200; @@ -74,6 +95,214 @@ function rememberSessionReferencedAsset(requestUrl) { } } +/* +FNXC:PWAOffline 2026-07-26-15:40: +The hashed-asset cap above left the OTHER unbounded writer in this file untouched: every GET /api/ response is put into the same CACHE_NAME and nothing ever evicted those entries. The dashboard issues a large and open-ended set of distinct /api/ URLs (per project, per task, per query string), so the API half grew without limit — the same iOS all-or-nothing per-origin quota hazard the asset cap exists to prevent, where blowing the bucket wipes localStorage (SWR board snapshot, `kb-dashboard-*` prefs) alongside the caches. + +Size is only half the defect. This cache is the OFFLINE FALLBACK: on a network failure a cached entry is served transparently to the app, which cannot tell it apart from a live response. With no expiry, an hours-old task list could be handed to the board during a brief radio blip — a correctness bug worse than the failed fetch it papers over. So the API entries get BOTH a count cap and a freshness bound. + +Why not "stop caching /api/ entirely": the fallback is cheap and does earn a narrow keep. Warm hydration is NOT what depends on it — the app hydrates from its own localStorage/session snapshots — but a mobile tab restored onto a waking radio issues its first API burst before the connection settles, and a seconds-to-minutes-old response there is strictly better than an error state. That value evaporates fast, hence a short TTL rather than an unbounded fallback. + +Freshness accounting, in precedence order: +1. `apiCacheTimestamps` — recorded at put time, same clock as the read, exact. +2. the response's `Date` header — Fusion serves its own API from Node, which always sets it, so a service worker that restarted (the common case after a discard) can still prove age. +3. otherwise UNKNOWN, which counts as stale. Fail-closed: an entry whose age cannot be proven is never served. The cost is one failed request that would have failed anyway without a cache; the alternative is exactly the unbounded-staleness bug being fixed. +A negative age (client clock behind the server's) is also treated as unprovable rather than clamped to fresh, so clock skew cannot mint an immortal entry. +*/ +const MAX_API_CACHE_ENTRIES = 100; + +/** Freshness bound for the /api/ offline fallback. Long enough to cover a radio blip, short enough that no stale board can surface. */ +const MAX_API_CACHE_AGE_MS = 5 * 60 * 1000; + +/* +FNXC:PWAOffline 2026-07-26-18:05: +WHICH /api/ responses may be written at all. Previously: every successful GET /api/*. That included +GET /api/settings and /api/settings/global, which return `daemonToken`, `githubAuthToken`, +`gitlabAuthToken`, and `ntfyAccessToken` as plaintext — so dashboard credentials were persisted to +durable Cache Storage, survived logout, token rotation, and project switch (nothing in the app touched +the caches API until the purge added in swrCache.ts), and were readable by any script on the origin. +The entry cap and TTL added earlier bound size and staleness; neither is a confidentiality control. + +ALLOW-list, not deny-list. A deny-list fails OPEN: every endpoint added later is cached until someone +remembers to exclude it, which is exactly how the credential endpoints got here. The allow-list fails +closed — a new endpoint is network-only until someone argues it into this list. + +What earns a place: only reads whose offline fallback has real value on the mobile-restore path this +whole feature targets (a restored tab issues its first API burst before the radio settles, where a +seconds-old body beats an error state) AND whose bodies carry no credential, secret, or token. +- `/api/tasks` and `/api/tasks/`: the board and the card the user was looking at. +- `/api/projects`: the project list the board is keyed by. +Everything else — settings, secrets, auth, agents, git, chat, artifacts, attachments — goes straight to +the network with no cache write and no fallback read. The cost is an error state on a blip for those +views; the alternative is deciding, endpoint by endpoint forever, whether a body is sensitive. +*/ +const API_CACHE_ALLOWLIST = [ + /^\/api\/tasks$/, + /^\/api\/tasks\/[^/]+$/, + /^\/api\/projects$/, +]; + +/* +FNXC:PWAOffline 2026-07-26-18:05: +Defense in depth over the allow-list, not a substitute for it. `appendTokenQuery` (auth.ts) puts the +dashboard bearer token in the URL as `fn_token=` for transports that cannot set headers +(TaskDetailModal attachments, artifactMediaUrl, file downloads). A cache key IS the URL, so caching such +a request would persist the token itself as a durable key. None of those paths are allow-listed today; +this guard makes that a property of the code rather than of the current allow-list's shape. +*/ +const CREDENTIAL_QUERY_PARAMS = ["fn_token", "token", "ticket", "access_token"]; + +/** + * FNXC:PWAOffline 2026-07-26-18:05: + * @param {string} requestUrl + * @returns {boolean} true when this URL may be written to / read from the offline fallback cache. + */ +function isCacheableApiUrl(requestUrl) { + try { + const url = new URL(requestUrl); + for (const param of CREDENTIAL_QUERY_PARAMS) { + if (url.searchParams.has(param)) { + return false; + } + } + return API_CACHE_ALLOWLIST.some((pattern) => pattern.test(url.pathname)); + } catch { + return false; + } +} + +/* +FNXC:PWAOffline 2026-07-26-18:05: +Durable put-time stamp. `apiCacheTimestamps` is a plain Map in the service worker's global scope, and +browsers idle-terminate a service worker after ~30s — which is precisely what happens while the tab is +backgrounded, the scenario this feature exists for. So on the next wake the SW cold-starts with that Map +EMPTY and every surviving entry's age had to be proven from the server `Date` header alone. That header +is a real fallback but it is the ORIGIN's send time, not our put time, and an intermediary or a response +replayed from an HTTP cache can make it arbitrarily older than the entry. Stamping our own header at put +time makes age provable across a worker restart with the same clock the read uses. + +Fails soft by design: if `Response`/`Headers` are unavailable, the status cannot carry a body (204/304), +or the body read throws, the caller falls back to putting the plain clone and age falls back to `Date`. +A failure here must never cost the response itself. +*/ +const SW_CACHED_AT_HEADER = "x-fusion-sw-cached-at"; + +/** + * @param {Response} response + * @returns {Promise} a stamped copy, or undefined when stamping is not possible. + */ +async function buildStampedResponse(response) { + if (typeof Response !== "function" || typeof Headers !== "function") { + return undefined; + } + if (!response || !response.headers || typeof response.clone !== "function") { + return undefined; + } + // Response's constructor rejects a body for these statuses; leave them to the plain-clone path. + if (response.status === 204 || response.status === 205 || response.status === 304 || !response.status) { + return undefined; + } + const headers = new Headers(response.headers); + headers.set(SW_CACHED_AT_HEADER, String(Date.now())); + const body = await response.clone().arrayBuffer(); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/* +FNXC:PWAOffline 2026-07-26-15:40: +Unlike hashed assets (written only on the cold path, when a new build arrives), EVERY api response is a +cache put, so scheduling a full keys() scan per put would put an O(cache) walk behind ordinary polling. +Pruning every Nth put bounds the API entry count at limit + N instead of limit exactly, which is fine: +the cap is a quota guard, not a contract. Expiry is NOT lazy in the way that matters — it is enforced on +every read of the fallback path; the prune-side expiry sweep only reclaims disk. +*/ +const API_PRUNE_PUT_INTERVAL = 10; + +/** @type {Map} api URL -> epoch ms of its cache put. Insertion-ordered, so the first entry is the oldest. */ +const apiCacheTimestamps = new Map(); + +let apiPutsSincePrune = 0; + +/** + * @param {string} requestUrl + * @returns {void} + */ +function rememberApiCacheTimestamp(requestUrl) { + apiCacheTimestamps.delete(requestUrl); + apiCacheTimestamps.set(requestUrl, Date.now()); + while (apiCacheTimestamps.size > MAX_API_CACHE_ENTRIES) { + const oldest = apiCacheTimestamps.keys().next(); + if (oldest.done) { + break; + } + apiCacheTimestamps.delete(oldest.value); + } +} + +/** + * @param {string} requestUrl + * @param {Response|undefined} cachedResponse + * @returns {number|undefined} epoch ms the entry was cached, or undefined when unprovable. + */ +function readApiCachedAtMillis(requestUrl, cachedResponse) { + const remembered = apiCacheTimestamps.get(requestUrl); + if (typeof remembered === "number") { + return remembered; + } + try { + const headers = cachedResponse && cachedResponse.headers ? cachedResponse.headers : null; + // FNXC:PWAOffline 2026-07-26-18:05: our own put-time stamp outranks `Date` — it survives the + // SW idle-termination that empties apiCacheTimestamps and is on the same clock as this read. + const stamped = headers ? headers.get(SW_CACHED_AT_HEADER) : null; + if (stamped) { + const parsedStamp = Number(stamped); + if (Number.isFinite(parsedStamp)) { + return parsedStamp; + } + } + const dateHeader = headers ? headers.get("date") : null; + if (dateHeader) { + const parsed = Date.parse(dateHeader); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } catch (headerError) { + console.warn("[sw] api cache date header read failed", headerError); + } + return undefined; +} + +/** + * @param {number|undefined} cachedAtMillis + * @returns {boolean} true only when the entry's age is provable AND within the freshness bound. + */ +function isFreshApiCacheEntry(cachedAtMillis) { + if (typeof cachedAtMillis !== "number" || !Number.isFinite(cachedAtMillis)) { + return false; + } + const age = Date.now() - cachedAtMillis; + return age >= 0 && age <= MAX_API_CACHE_AGE_MS; +} + +/** + * @param {string} requestUrl + * @returns {boolean} + */ +function isApiCacheUrl(requestUrl) { + try { + const pathname = new URL(requestUrl).pathname; + // /api/events* never reaches the cache (SSE bypasses the SW), so it is not a prunable class. + return pathname.startsWith("/api/") && pathname !== "/api/events" && !pathname.startsWith("/api/events/"); + } catch { + return false; + } +} + /** Prune runs are single-flight; overlapping cold-path misses must not scan/delete concurrently. */ let immutablePruneInFlight = false; @@ -96,55 +325,126 @@ function isPrunableAssetUrl(requestUrl) { } } +/* +FNXC:PWAOffline 2026-07-26-15:40: +Both bounded classes (hashed assets, /api/ responses) share ONE policy-driven sweep over ONE keys() +scan rather than two parallel implementations walking the cache separately. A policy declares which +URLs it owns, its entry cap, which entries are pinned, and which are provably expired. + +`isExpired` is deliberately allowed to answer only for entries whose age is PROVABLE. An /api/ entry +with no recorded timestamp is never served (see isFreshApiCacheEntry) but is also not force-evicted +here; the cap reclaims it in insertion order. This keeps the sweep from deleting entries it cannot +reason about — e.g. those written by a previous SW session — as a side effect of an asset prune. + +Amended 2026-07-26-18:05: be explicit that after an idle-termination cold start BOTH in-memory +structures are empty, so a prune then sees zero pinned assets and zero provably-expired API entries and +degrades to a pure oldest-first cap sweep. That is correct behavior, not a gap: the cap is the quota +guard, and the freshness guarantee lives on the READ path (isFreshApiCacheEntry via the durable +SW_CACHED_AT_HEADER stamp), which never depends on these maps surviving. `isExpired` deliberately does +not open each entry to read that header — that would turn the sweep into an O(cache) match() storm on +the hot path to reclaim disk it will reclaim by cap anyway. + +Entries outside every policy (the navigation shell, unhashed assets, fonts, icons) are a small fixed +set and stay untouched, as before. + +@type {Array<{name: string, limit: number, owns: (url: string) => boolean, isPinned: (url: string) => boolean, isExpired: (url: string) => boolean, forget: (url: string) => void}>} +*/ +const CACHE_PRUNE_POLICIES = [ + { + name: "immutable-asset", + limit: MAX_IMMUTABLE_CACHE_ENTRIES, + owns: isPrunableAssetUrl, + isPinned: (url) => sessionReferencedAssets.has(url), + isExpired: () => false, + forget: () => undefined, + }, + { + name: "api", + limit: MAX_API_CACHE_ENTRIES, + owns: isApiCacheUrl, + isPinned: () => false, + isExpired: (url) => { + const cachedAt = apiCacheTimestamps.get(url); + return typeof cachedAt === "number" && !isFreshApiCacheEntry(cachedAt); + }, + forget: (url) => { + apiCacheTimestamps.delete(url); + }, + }, +]; + /** - * FNXC:PWAOffline 2026-07-26-14:05: - * Evict oldest-inserted hashed assets until the hashed-entry count is back under the cap, skipping - * anything this session referenced. If every over-cap entry is session-referenced the sweep simply - * does less work than requested — never evicting a live chunk is more important than hitting the cap - * exactly. Fully defensive: it is invoked fire-and-forget so a throw, a rejected delete, or a slow - * keys() scan can never delay or fail the fetch response it was triggered from. + * FNXC:PWAOffline 2026-07-26-14:05 (generalized 2026-07-26-15:40): + * Evict per policy until each bounded class is back under its cap: provably-expired entries first + * (they can never be served, so they are pure reclaimed quota), then oldest-inserted, skipping + * anything pinned. If every over-cap entry is pinned the sweep simply does less work than requested — + * never evicting a live chunk is more important than hitting the cap exactly. Fully defensive: it is + * invoked fire-and-forget so a throw, a rejected delete, or a slow keys() scan can never delay or + * fail the fetch response it was triggered from. * * @param {Cache} cache * @returns {Promise} */ -async function pruneImmutableAssetCache(cache) { +async function pruneBoundedCacheEntries(cache) { if (immutablePruneInFlight) { return; } immutablePruneInFlight = true; try { const keys = await cache.keys(); - const evictable = []; - let hashedTotal = 0; + const buckets = CACHE_PRUNE_POLICIES.map(() => ({ total: 0, expired: [], evictable: [] })); for (const cachedRequest of keys) { - if (!cachedRequest || !isPrunableAssetUrl(cachedRequest.url)) { + if (!cachedRequest) { continue; } - hashedTotal += 1; - if (!sessionReferencedAssets.has(cachedRequest.url)) { - evictable.push(cachedRequest); - } - } - - let overflow = hashedTotal - MAX_IMMUTABLE_CACHE_ENTRIES; - if (overflow <= 0) { - return; - } - - for (const cachedRequest of evictable) { - if (overflow <= 0) { + for (let index = 0; index < CACHE_PRUNE_POLICIES.length; index += 1) { + const policy = CACHE_PRUNE_POLICIES[index]; + if (!policy.owns(cachedRequest.url)) { + continue; + } + const bucket = buckets[index]; + bucket.total += 1; + if (policy.isExpired(cachedRequest.url)) { + bucket.expired.push(cachedRequest); + } else if (!policy.isPinned(cachedRequest.url)) { + bucket.evictable.push(cachedRequest); + } break; } - try { - await cache.delete(cachedRequest); - overflow -= 1; - } catch (deleteError) { - console.warn("[sw] immutable asset eviction failed", deleteError); + } + + for (let index = 0; index < CACHE_PRUNE_POLICIES.length; index += 1) { + const policy = CACHE_PRUNE_POLICIES[index]; + const bucket = buckets[index]; + let overflow = bucket.total - policy.limit; + + // Expired entries are dropped regardless of overflow; they are unservable dead weight. + for (const cachedRequest of bucket.expired) { + try { + await cache.delete(cachedRequest); + policy.forget(cachedRequest.url); + overflow -= 1; + } catch (deleteError) { + console.warn(`[sw] ${policy.name} expired eviction failed`, deleteError); + } + } + + for (const cachedRequest of bucket.evictable) { + if (overflow <= 0) { + break; + } + try { + await cache.delete(cachedRequest); + policy.forget(cachedRequest.url); + overflow -= 1; + } catch (deleteError) { + console.warn(`[sw] ${policy.name} eviction failed`, deleteError); + } } } } catch (error) { - console.warn("[sw] immutable asset prune failed", error); + console.warn("[sw] cache prune failed", error); } finally { immutablePruneInFlight = false; } @@ -159,14 +459,31 @@ async function pruneImmutableAssetCache(cache) { * @param {Cache} cache * @returns {void} */ -function scheduleImmutableAssetPrune(cache) { +function schedulePrune(cache) { try { - void pruneImmutableAssetCache(cache); + void pruneBoundedCacheEntries(cache); } catch (error) { - console.warn("[sw] immutable asset prune scheduling failed", error); + console.warn("[sw] cache prune scheduling failed", error); } } +/** + * FNXC:PWAOffline 2026-07-26-15:40: + * Throttled prune trigger for the /api/ hot path — see API_PRUNE_PUT_INTERVAL. The counter advances + * before the guard so a throwing prune cannot wedge the interval. + * + * @param {Cache} cache + * @returns {void} + */ +function scheduleApiPrune(cache) { + apiPutsSincePrune += 1; + if (apiPutsSincePrune < API_PRUNE_PUT_INTERVAL) { + return; + } + apiPutsSincePrune = 0; + schedulePrune(cache); +} + const APP_SHELL_URLS = [ "/", "/index.html", @@ -176,10 +493,38 @@ const APP_SHELL_URLS = [ "/icons/icon-512.png", ]; +/* +FNXC:PWAOffline 2026-07-26-18:05: +Operator-triggered Cache Storage purge, the service-worker half of `clearAllLocalCache()` (swrCache.ts). +Settings -> "Clear all cached data" wipes localStorage and then immediately reloads the page, which can +abort an in-page `caches.delete()` mid-flight. The service worker is NOT torn down by that reload, so +routing the purge through it is what makes the delete actually land. The page still performs its own +direct purge as well: a page with no controlling worker (first load, dev without SW) would otherwise get +no purge at all. Both paths are idempotent — deleting an absent cache is a no-op. +*/ +const PURGE_CACHES_MESSAGE = "PURGE_CACHES"; + self.addEventListener("message", (event) => { if (event.data && event.data.type === "SKIP_WAITING") { self.skipWaiting(); } + if (event.data && event.data.type === PURGE_CACHES_MESSAGE) { + const purge = (async () => { + try { + const keys = await caches.keys(); + await Promise.all(keys.map((key) => caches.delete(key))); + // In-memory bookkeeping describes entries that no longer exist; leaving it would let a + // subsequent read believe a deleted URL still has a provable put time. + apiCacheTimestamps.clear(); + sessionReferencedAssets.clear(); + } catch (error) { + console.warn("[sw] cache purge failed", error); + } + })(); + if (event.waitUntil) { + event.waitUntil(purge); + } + } }); self.addEventListener("install", (event) => { @@ -207,7 +552,7 @@ self.addEventListener("activate", (event) => { // CACHE_NAME bump; this bounds the CURRENT generation on every SW activation too. try { const cache = await caches.open(CACHE_NAME); - await pruneImmutableAssetCache(cache); + await pruneBoundedCacheEntries(cache); } catch (pruneError) { console.warn("[sw] activate prune failed", pruneError); } @@ -279,22 +624,76 @@ self.addEventListener("fetch", (event) => { return; } + /* + FNXC:PWAOffline 2026-07-26-15:40 (amended 2026-07-26-18:05): + /api/ stays network-first; the cache is a fallback for a failed request only, and ONLY for the + allow-listed, credential-free URLs isCacheableApiUrl() admits. Three bounds apply here + (rationale at MAX_API_CACHE_ENTRIES): the put records a timestamp and throttle-schedules the shared + prune so the entry count cannot grow without limit, and the fallback read refuses any entry whose + age is not provably within MAX_API_CACHE_AGE_MS — an expired or unprovable entry is deleted and the + original network error is rethrown, so the app sees a failed request instead of silently rendering + stale data it cannot distinguish from live data. + */ if (isApiRequest) { + /* + FNXC:PWAOffline 2026-07-26-18:05: + Confidentiality gate, evaluated BEFORE any cache is opened: a non-allow-listed /api/ URL is passed + through untouched — no put, and no fallback read either. The read side matters as much as the write + side: an entry left behind by an older worker generation must not become servable just because this + generation stopped writing it. Returning without respondWith() lets the browser perform the request + directly, which is also the cheapest possible path. + */ + if (!isCacheableApiUrl(request.url)) { + return; + } event.respondWith((async () => { try { const networkResponse = await fetch(request); + /* + FNXC:PWAOffline 2026-07-26-18:05: + Only `response.ok` is cached — the same rule the immutable-asset branch already applied, which + this branch was missing. Caching a failure body makes the offline fallback actively wrong rather + than merely stale: a 401 taken right after a token rotation, or a 500 from a restarting daemon, + would be replayed on the next network blip as a response the app cannot distinguish from live, + and an error body carries none of the freshness value the fallback exists for. + */ + if (!networkResponse || !networkResponse.ok) { + return networkResponse; + } try { const cache = await caches.open(CACHE_NAME); - await cache.put(request, networkResponse.clone()); + // FNXC:PWAOffline 2026-07-26-18:05: stamp the put time into the stored entry so a + // cold-started worker (see buildStampedResponse) can still prove this entry's age. + let entryToCache = networkResponse.clone(); + try { + const stamped = await buildStampedResponse(networkResponse); + if (stamped) { + entryToCache = stamped; + } + } catch (stampError) { + console.warn("[sw] api cache stamp failed", stampError); + } + await cache.put(request, entryToCache); + rememberApiCacheTimestamp(request.url); + scheduleApiPrune(cache); } catch (cacheError) { console.warn("[sw] api cache put failed", cacheError); } return networkResponse; } catch (networkError) { try { - const cachedResponse = await caches.match(request); + const cache = await caches.open(CACHE_NAME); + const cachedResponse = await cache.match(request); if (cachedResponse) { - return cachedResponse; + if (isFreshApiCacheEntry(readApiCachedAtMillis(request.url, cachedResponse))) { + return cachedResponse; + } + try { + await cache.delete(request); + apiCacheTimestamps.delete(request.url); + } catch (deleteError) { + console.warn("[sw] api stale fallback eviction failed", deleteError); + } } } catch (cacheError) { console.warn("[sw] api cache lookup failed", cacheError); @@ -329,7 +728,7 @@ self.addEventListener("fetch", (event) => { if (networkResponse && networkResponse.ok) { try { await cache.put(request, networkResponse.clone()); - scheduleImmutableAssetPrune(cache); + schedulePrune(cache); } catch (cacheError) { console.warn("[sw] immutable asset cache put failed", cacheError); } diff --git a/packages/dashboard/app/sse-bus.ts b/packages/dashboard/app/sse-bus.ts index bc2bdb5fdd..57678d22de 100644 --- a/packages/dashboard/app/sse-bus.ts +++ b/packages/dashboard/app/sse-bus.ts @@ -1,4 +1,5 @@ import { appendTokenQuery } from "./auth"; +import { visibleEdgeStaggerDelayMs } from "./hooks/visibilitySuspension"; import { pushTrace } from "./utils/dashboardTraceBuffer"; import { recordResumeEvent } from "./utils/resumeInstrumentation"; @@ -60,13 +61,36 @@ interface Channel { * registered, so the visibilitychange reopen path can bring it back for the same consumers. * `openChannel` refuses to run while it is set so nothing (reconnect timers, late subscribes) * can re-establish traffic behind the suspend. + * + * FNXC:DashboardSSE 2026-07-26-16:20: + * CORRECTION of the claim previously written here. This flag alone did NOT stop "late subscribes" + * re-establishing traffic, and asserting that it did hid a real hole: `closeChannel` deletes the + * channel when the last subscriber leaves, so a later `subscribeSse` for the same URL builds a + * fresh channel with `suspended: false` and opens a socket plus a 30s keepalive for the rest of + * the hidden window. Per-channel state cannot survive channel recreation. The authority is now + * the module-level `isHiddenSuspendElapsed()` condition below; this flag is only the per-channel + * record of "this channel was torn down by the suspend", used by the resume paths. */ suspended: boolean; + /** + * FNXC:DashboardSSE 2026-07-26-16:20: + * Pending staggered onReconnect fan-out timers (see `fanOutReconnect`). Tracked per channel so + * teardown/suspend/close can cancel them: a hidden tab must hold NO armed timer, and a cancelled + * reconnect must not deliver a resync signal into subscribers of a channel that no longer exists. + */ + reconnectFanoutTimers: Set>; } const channels = new Map(); let lastVisibilityReopenAt = 0; let hiddenSuspendTimer: ReturnType | null = null; +/** + * FNXC:DashboardSSE 2026-07-26-16:20: + * Timestamp of the transition into `hidden`, i.e. the anchor the suspend threshold is measured from. + * Module-level (not per-channel) because it must survive a channel being destroyed and rebuilt by a + * subscribe/unsubscribe cycle inside the hidden window. + */ +let hiddenSinceMs: number | null = null; function createClientId(): string { const cryptoApi = typeof globalThis !== "undefined" ? globalThis.crypto : undefined; @@ -225,6 +249,68 @@ function startClientKeepalive(channel: Channel): void { * `beforeunload` does, including the unload path, and it is the only one of the two that fires * when the page is frozen into bfcache. Restore performance is the point of this deletion. */ +/* +FNXC:DashboardSSE 2026-07-26-16:20: +Hidden-suspend authority, module-level rather than per-channel. + +Requirement: once the document has been hidden past SSE_HIDDEN_SUSPEND_DELAY_MS the page must hold no +EventSource and no armed timer, because that background work is what makes a mobile browser discard the +tab (the operator sees a white-splash reload). The previous implementation enforced this with a +per-channel `suspended` flag armed by a single hidden-transition timer. Two gaps followed, both silent: +1. `hiddenSuspendTimer` is nulled when it fires, and only a hidden TRANSITION re-arms it — which cannot + happen again while the tab is already hidden. A channel first subscribed during the hidden window was + therefore created with `suspended: false` and opened a live socket + keepalive for the rest of it. +2. `closeChannel` deletes the channel at zero subscribers, so the flag itself is discarded by any + subscribe/unsubscribe cycle. +Both are fixed by deriving suspension from `document.visibilityState` plus elapsed hidden time, which no +channel lifecycle event can reset, and by letting `openChannel` re-arm the grace timer when it opens a +channel during the grace window (so a channel born mid-window is still torn down at the threshold). +*/ +function isHiddenSuspendElapsed(): boolean { + if (typeof document === "undefined") return false; + if (document.visibilityState !== "hidden") return false; + if (hiddenSinceMs === null) return false; + return Date.now() - hiddenSinceMs >= SSE_HIDDEN_SUSPEND_DELAY_MS; +} + +/* +FNXC:DashboardSSE 2026-07-26-10:28: +Hidden-tab suspend. Only tears the transport down if the document is STILL hidden when the grace +timer expires, so app-switching away for a few seconds costs nothing. Subscribers and channel +registration survive; the module-level hidden condition blocks any reopen until the tab is visible. +*/ +function suspendChannelsWhileHidden(): void { + hiddenSuspendTimer = null; + if (typeof document === "undefined" || document.visibilityState === "visible") return; + + const suspendable = Array.from(channels.values()).filter((c) => !c.closed && !c.suspended); + if (suspendable.length === 0) return; + + console.info("[sse-bus] suspend-hidden", { channelCount: suspendable.length }); + pushTrace("sse-bus", "suspend-hidden", { channelCount: suspendable.length }); + for (const channel of suspendable) { + tearDownChannelTransport(channel); + channel.suspended = true; + } +} + +/** Arm (or re-arm) the grace timer for the REMAINDER of the current hidden window. */ +function scheduleHiddenSuspend(): void { + if (typeof document === "undefined" || document.visibilityState === "visible") return; + if (hiddenSinceMs === null) hiddenSinceMs = Date.now(); + if (hiddenSuspendTimer) return; + const remainingMs = Math.max(0, SSE_HIDDEN_SUSPEND_DELAY_MS - (Date.now() - hiddenSinceMs)); + hiddenSuspendTimer = setTimeout(suspendChannelsWhileHidden, remainingMs); +} + +function clearHiddenSuspendState(): void { + hiddenSinceMs = null; + if (hiddenSuspendTimer) { + clearTimeout(hiddenSuspendTimer); + hiddenSuspendTimer = null; + } +} + if (typeof window !== "undefined" && typeof document !== "undefined") { const closeAllChannels = () => { console.info("[sse-bus] pagehide", { channelCount: channels.size }); @@ -236,32 +322,6 @@ if (typeof window !== "undefined" && typeof document !== "undefined") { } }; - /* - FNXC:DashboardSSE 2026-07-26-10:28: - Hidden-tab suspend. Only tears the transport down if the document is STILL hidden when the grace - timer expires, so app-switching away for a few seconds costs nothing. Subscribers and channel - registration survive; `suspended` blocks any reopen until visibilitychange says otherwise. - */ - const suspendChannelsWhileHidden = () => { - hiddenSuspendTimer = null; - if (document.visibilityState === "visible") return; - - const suspendable = Array.from(channels.values()).filter((c) => !c.closed && !c.suspended); - if (suspendable.length === 0) return; - - console.info("[sse-bus] suspend-hidden", { channelCount: suspendable.length }); - pushTrace("sse-bus", "suspend-hidden", { channelCount: suspendable.length }); - for (const channel of suspendable) { - tearDownChannelTransport(channel); - channel.suspended = true; - } - }; - - const scheduleHiddenSuspend = () => { - if (hiddenSuspendTimer) return; - hiddenSuspendTimer = setTimeout(suspendChannelsWhileHidden, SSE_HIDDEN_SUSPEND_DELAY_MS); - }; - const reopenSubscribedChannels = (event: PageTransitionEvent) => { console.info("[sse-bus] pageshow", { persisted: event.persisted, channelCount: channels.size }); pushTrace("sse-bus", "pageshow", { persisted: event.persisted, channelCount: channels.size }); @@ -282,7 +342,10 @@ if (typeof window !== "undefined" && typeof document !== "undefined") { with permanently silent channels. Only release when the restored page is actually visible; a pageshow into a still-hidden document keeps the suspend. */ - if (document.visibilityState !== "hidden") channel.suspended = false; + if (document.visibilityState !== "hidden") { + channel.suspended = false; + clearHiddenSuspendState(); + } channel.closed = false; openChannel(channel); } @@ -345,10 +408,7 @@ if (typeof window !== "undefined" && typeof document !== "undefined") { const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - if (hiddenSuspendTimer) { - clearTimeout(hiddenSuspendTimer); - hiddenSuspendTimer = null; - } + clearHiddenSuspendState(); reopenVisibleChannels(); return; } @@ -381,6 +441,7 @@ function tearDownChannelTransport(channel: Channel): void { clearTimeout(channel.reconnectTimer); channel.reconnectTimer = null; } + cancelReconnectFanout(channel); sendDisconnectBeacon(channel); if (channel.es) { try { @@ -393,6 +454,59 @@ function tearDownChannelTransport(channel: Channel): void { channel.nativeListeners.clear(); } +/* +FNXC:DashboardSSE 2026-07-26-16:20: +Staggered onReconnect fan-out. + +Requirement: a reconnect must not issue every subscriber's resync fetch in one tick. ~28 subscribers now +declare `onReconnect`, and a browser allows ~6 concurrent HTTP/1.1 connections per origin. A same-tick +burst on a waking mobile radio saturates that pool, the EventSource reconnect cannot get a slot, it +errors, forceReconnect runs again, and the burst repeats every RECONNECT_DELAY_MS — the resume path +starves itself. + +This is the SAME defect and the SAME remedy as the polling side's visible-edge stagger, so it uses the +SAME primitive: `visibleEdgeStaggerDelayMs` from `hooks/visibilitySuspension.ts` gives a deterministic +slot = (index % SLOTS) * STEP, derived from insertion order, no randomness, slot 0 synchronous so a +single subscriber is unchanged, and the spread bounded by the window regardless of subscriber count. + +FNXC:DashboardSSE 2026-07-26-17:40: +CORRECTION — this block previously carried a private copy of the constants and the slot formula, marked +KNOWN DUPLICATION because the primitive was not yet exported. It is exported now and this file imports +it. Do NOT reintroduce local copies: the poll fan-out and the SSE fan-out burst on the SAME +visibilitychange against the SAME ~6-connection-per-origin cap, so two independently-tuned staggers +would each bound their own spread while leaving their union unbounded — which is the bug, not the fix. +A hand-copied helper that drifts from its original is the documented root cause of several defects in +this change set; one primitive, one place to tune it. +*/ +function cancelReconnectFanout(channel: Channel): void { + for (const timer of channel.reconnectFanoutTimers) clearTimeout(timer); + channel.reconnectFanoutTimers.clear(); +} + +function fanOutReconnect(channel: Channel): void { + const subs = Array.from(channel.subscribers).filter((sub) => !!sub.onReconnect); + if (subs.length === 0) return; + + subs.forEach((sub, index) => { + const delayMs = visibleEdgeStaggerDelayMs(index); + if (delayMs === 0) { + sub.onReconnect?.(); + return; + } + const timer = setTimeout(() => { + channel.reconnectFanoutTimers.delete(timer); + // Re-check at fire time: the channel may have been closed, suspended, replaced, or the + // subscriber unmounted during the stagger window. A resync signal delivered after any of those + // is either wasted work on a hidden tab or an update into a torn-down consumer. + if (channel.closed || channel.suspended) return; + if (channels.get(channel.url) !== channel) return; + if (!channel.subscribers.has(sub)) return; + sub.onReconnect?.(); + }, delayMs); + channel.reconnectFanoutTimers.add(timer); + }); +} + function resetHeartbeat(channel: Channel): void { if (channel.heartbeatTimer) clearTimeout(channel.heartbeatTimer); channel.heartbeatTimer = setTimeout(() => { @@ -429,6 +543,7 @@ function forceReconnect(channel: Channel, cause: "heartbeat-timeout" | "error" | channel.es = null; } stopClientKeepalive(channel); + cancelReconnectFanout(channel); channel.nativeListeners.clear(); // A suspended channel is deliberately quiet: never let a late error/heartbeat callback from the @@ -436,16 +551,24 @@ function forceReconnect(channel: Channel, cause: "heartbeat-timeout" | "error" | if (channel.closed || channel.suspended) return; if (channel.subscribers.size === 0 || channel.reconnectTimer) return; - // Guard against calling onReconnect callbacks for a channel that has been - // closed while the heartbeat timer fired. This prevents stale SSE events from - // firing into unsubscribed/mounted-out consumers during rapid view switches. + // Guard against reconnecting a channel that has been closed while the heartbeat timer fired. const ch = channels.get(channel.url); if (!ch || ch !== channel) return; - // A teardown means events may have been missed while the stream was - // down. Signal resync to each subscriber so they can refetch - // authoritative state. - for (const sub of channel.subscribers) sub.onReconnect?.(); + /* + FNXC:DashboardSSE 2026-07-26-16:20: + The synchronous `for (const sub of channel.subscribers) sub.onReconnect?.()` that used to run here was + REMOVED and must not be reinstated. It made every reconnect cycle fire onReconnect TWICE ~3s apart: once + here, and again when the reconnect's EventSource opened (the `open` handler treats any open after the + first as a reconnect). With expensive handlers now wired to it — AgentDetailView refetches an unbounded + run log and replaces its whole buffer — a 1500-entry log was refetched twice per reconnect, and + `useAgentLogs`' resyncInFlightRef only dedupes OVERLAPPING resyncs, not two 3s apart. + The successful `open` is now the single authority, for two reasons: (1) a reconnect that never succeeds + must not claim to have resynced, and (2) firing here put the resync burst BEFORE the EventSource had a + connection slot, so the burst competed with the reconnect it was supposed to follow — the exact + pool-starvation loop the staggered fan-out exists to prevent. A failed attempt errors and re-enters + forceReconnect, so each cycle still ends in exactly one signal once the stream is actually back. + */ channel.reconnectTimer = setTimeout(() => { channel.reconnectTimer = null; @@ -487,10 +610,23 @@ function openChannel(channel: Channel): void { }); if (channel.es) return; if (channel.closed) return; - // Hidden-suspended: the only legitimate reopen is the visibilitychange resume path, which clears - // `suspended` first. Everything else (reconnect timers, a component subscribing while the tab is - // backgrounded) must stay quiet so the page keeps no live connection while hidden. + // Hidden-suspended: the only legitimate reopen is the visibilitychange/pageshow resume path, which + // clears `suspended` first. Everything else (reconnect timers, a component subscribing while the tab + // is backgrounded) must stay quiet so the page keeps no live connection while hidden. if (channel.suspended) return; + /* + FNXC:DashboardSSE 2026-07-26-16:20: + Module-level suspend gate. A channel created DURING the hidden window (a component subscribing to a + URL nobody held, or a subscribe/unsubscribe cycle that deleted and rebuilt the channel) starts with + `suspended: false`, so the per-channel flag alone let it open a socket and a 30s keepalive for the + rest of the hidden period — reinstating exactly the background work the suspend exists to remove. + Mark it suspended so the resume paths adopt it like any other suspended channel. + */ + if (isHiddenSuspendElapsed()) { + channel.suspended = true; + pushTrace("sse-bus", "openChannel-blocked-hidden", { url: channel.url }); + return; + } if (channel.reconnectTimer) { clearTimeout(channel.reconnectTimer); channel.reconnectTimer = null; @@ -502,15 +638,19 @@ function openChannel(channel: Channel): void { const es = new EventSource(appendTokenQuery(appendClientIdQuery(channel.url))); channel.es = es; startClientKeepalive(channel); + // Opened inside the grace window: re-arm the suspend timer for its remainder so a channel born + // mid-window is still torn down at the threshold (the hidden transition that armed the original + // timer cannot fire again while the tab stays hidden). + scheduleHiddenSuspend(); es.addEventListener("open", () => { resetHeartbeat(channel); const reconnect = channel.hasOpenedOnce; channel.hasOpenedOnce = true; - for (const sub of channel.subscribers) { - sub.onOpen?.(); - if (reconnect) sub.onReconnect?.(); - } + // onOpen is a cheap connection-status signal — no stagger. onReconnect triggers refetches, so it + // goes through the staggered fan-out. + for (const sub of channel.subscribers) sub.onOpen?.(); + if (reconnect) fanOutReconnect(channel); }); es.addEventListener("error", (event) => { @@ -573,6 +713,7 @@ function closeChannel(channel: Channel): void { channel.closed = true; if (channel.heartbeatTimer) clearTimeout(channel.heartbeatTimer); stopClientKeepalive(channel); + cancelReconnectFanout(channel); if (channel.reconnectTimer) clearTimeout(channel.reconnectTimer); if (channel.es) channel.es.close(); channel.es = null; @@ -654,6 +795,7 @@ export function subscribeSse(url: string, sub: SseSubscription = {}): () => void hasOpenedOnce: false, closed: false, suspended: false, + reconnectFanoutTimers: new Set(), }; channels.set(url, channel); } @@ -709,12 +851,9 @@ export function __resetSseBus(): void { resyncGapsByUrl.clear(); memoryClientId = null; lastVisibilityReopenAt = 0; - // The hidden-suspend grace timer outlives channels; leaving it armed retains a handle and can - // fire into a later test file's bus. - if (hiddenSuspendTimer) { - clearTimeout(hiddenSuspendTimer); - hiddenSuspendTimer = null; - } + // The hidden-suspend grace timer and its hidden-since anchor outlive channels; leaving either set + // retains a handle and can fire into (or silently suspend) a later test file's bus. + clearHiddenSuspendState(); } /** Test-only: inspect the number of live channels. */ diff --git a/packages/dashboard/app/utils/__tests__/agentHealth-stale-clock.test.ts b/packages/dashboard/app/utils/__tests__/agentHealth-stale-clock.test.ts new file mode 100644 index 0000000000..a2a613fa69 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/agentHealth-stale-clock.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { getAgentHealthStatus, getAgentHealthColorVar } from "../agentHealth"; +import { elapsedSinceMs, isOverdue } from "../dataFreshness"; +import type { Agent } from "../../api"; + +/* +FNXC:MobileTabDiscard 2026-07-26-10:16: +Regression coverage for the dataAsOfMs invariant on the AGENT surface. After a mobile tab discard the +agents list hydrates from an SWR snapshot that can be hours old; heartbeat freshness must be measured +against the age of that snapshot, not wall-clock now, or every healthy agent renders "Unresponsive". +Same defect class as the task "stuck" false positive (utils/taskStuck.ts). + +The assertions are two-sided on purpose: seeding dataAsOfMs must not blind the label either — an agent +genuinely overdue RELATIVE TO THE SAME SNAPSHOT still reports Unresponsive. +*/ + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; + +/** Wall clock at render time — two hours after the snapshot was written. */ +const NOW = new Date("2026-04-10T12:00:00.000Z").getTime(); +/** When the hydrated agents snapshot was last confirmed fresh by the server. */ +const SNAPSHOT_SAVED_AT = NOW - 2 * HOUR; + +/** + * 15m configured interval -> staleness threshold is 15m * 4 = 60m (above the 5m floor). + * Chosen so a 2h-old snapshot straddles the threshold: anything measured against `Date.now()` is + * unconditionally past it, which is exactly the false positive under test. + */ +const INTERVAL_MS = 15 * MINUTE; +const THRESHOLD_MS = 60 * MINUTE; + +type AgentHealthInput = Pick< + Agent, + "state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig" | "metadata" | "name" | "role" | "taskId" +>; + +function makeAgent(lastHeartbeatAtMs: number): AgentHealthInput { + return { + name: "durable-agent", + role: "engineer", + state: "active", + taskId: undefined, + metadata: {}, + lastError: undefined, + pauseReason: undefined, + runtimeConfig: { enabled: true, heartbeatIntervalMs: INTERVAL_MS }, + lastHeartbeatAt: new Date(lastHeartbeatAtMs).toISOString(), + }; +} + +describe("agentHealth freshness is measured against the data's age, not wall-clock now", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not report Unresponsive for a heartbeat that is recent relative to a 2h-old snapshot", () => { + // Beat landed 1 minute before the snapshot was written: 1m of data-relative age vs a 60m threshold. + const agent = makeAgent(SNAPSHOT_SAVED_AT - MINUTE); + + const status = getAgentHealthStatus(agent, 1, SNAPSHOT_SAVED_AT); + + expect(status.label).toBe("Healthy"); + expect(status.reason).toBeUndefined(); + }); + + it("still reports Unresponsive for a heartbeat genuinely overdue relative to that same snapshot", () => { + // Beat landed 90 minutes before the snapshot was written: 90m of data-relative age, past 60m. + const agent = makeAgent(SNAPSHOT_SAVED_AT - 90 * MINUTE); + + const status = getAgentHealthStatus(agent, 1, SNAPSHOT_SAVED_AT); + + expect(status.label).toBe("Unresponsive"); + // The elapsed figure quoted to the operator is data-relative too — not the 3h30m wall-clock lie. + expect(status.reason).toBe("No heartbeat for 1h 30m (threshold: 1h)"); + }); + + it("reproduces the original defect when dataAsOfMs is omitted (documents the wall-clock fallback)", () => { + const agent = makeAgent(SNAPSHOT_SAVED_AT - MINUTE); + + // This is the pre-fix behavior every hydrated render used to take. + expect(getAgentHealthStatus(agent, 1).label).toBe("Unresponsive"); + // Live data is unaffected by the fix: a beat one minute before NOW is Healthy either way. + expect(getAgentHealthStatus(makeAgent(NOW - MINUTE), 1).label).toBe("Healthy"); + expect(getAgentHealthStatus(makeAgent(NOW - MINUTE), 1, NOW).label).toBe("Healthy"); + }); + + it("threshold boundary is evaluated in data-relative time", () => { + expect(getAgentHealthStatus(makeAgent(SNAPSHOT_SAVED_AT - THRESHOLD_MS), 1, SNAPSHOT_SAVED_AT).label) + .toBe("Healthy"); + expect(getAgentHealthStatus(makeAgent(SNAPSHOT_SAVED_AT - THRESHOLD_MS - 1), 1, SNAPSHOT_SAVED_AT).label) + .toBe("Unresponsive"); + }); + + it("clamps a heartbeat newer than its own snapshot instead of reporting negative age", () => { + // SSE can deliver a beat after the envelope was written; that is not proof of staleness. + const status = getAgentHealthStatus(makeAgent(SNAPSHOT_SAVED_AT + 5 * MINUTE), 1, SNAPSHOT_SAVED_AT); + expect(status.label).toBe("Healthy"); + }); + + it("keeps the invalid-timestamp verdict independent of the clock source", () => { + const agent: AgentHealthInput = { ...makeAgent(NOW), lastHeartbeatAt: "not-a-date" }; + expect(getAgentHealthStatus(agent, 1, SNAPSHOT_SAVED_AT).reason).toBe("Last heartbeat timestamp is invalid"); + expect(getAgentHealthStatus(agent, 1).reason).toBe("Last heartbeat timestamp is invalid"); + }); + + it("getAgentHealthColorVar honors the same data-relative clock", () => { + const fresh = makeAgent(SNAPSHOT_SAVED_AT - MINUTE); + expect(getAgentHealthColorVar(fresh, SNAPSHOT_SAVED_AT)).toBe("--state-active-text"); + expect(getAgentHealthColorVar(fresh)).toBe("--state-error-text"); + }); +}); + +describe("dataFreshness helper", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("ages a timestamp against dataAsOfMs and falls back to Date.now() only when it is undefined", () => { + expect(elapsedSinceMs(SNAPSHOT_SAVED_AT - MINUTE, SNAPSHOT_SAVED_AT)).toBe(MINUTE); + expect(elapsedSinceMs(SNAPSHOT_SAVED_AT - MINUTE, undefined)).toBe(2 * HOUR + MINUTE); + }); + + it("clamps negative age to zero and never calls an unparseable timestamp overdue", () => { + expect(elapsedSinceMs(NOW + MINUTE, NOW)).toBe(0); + expect(isOverdue(Number.NaN, MINUTE, NOW)).toBe(false); + }); + + it("uses a strict greater-than comparison at the threshold", () => { + expect(isOverdue(NOW - MINUTE, MINUTE, NOW)).toBe(false); + expect(isOverdue(NOW - MINUTE - 1, MINUTE, NOW)).toBe(true); + }); +}); diff --git a/packages/dashboard/app/utils/agentHealth.tsx b/packages/dashboard/app/utils/agentHealth.tsx index 9c0849075a..1f78396c22 100644 --- a/packages/dashboard/app/utils/agentHealth.tsx +++ b/packages/dashboard/app/utils/agentHealth.tsx @@ -2,6 +2,7 @@ import type { JSX } from "react"; import { Bot, Heart, Activity, Pause } from "lucide-react"; import type { Agent } from "../api"; import { resolveHeartbeatIntervalMs } from "./heartbeatIntervals"; +import { elapsedSinceMs } from "./dataFreshness"; // Heartbeat scheduling depends on both state and `runtimeConfig.enabled`. // Durable agents with heartbeat disabled should render distinctly from healthy @@ -118,6 +119,9 @@ function isTaskWorkerAgent(agent: AgentHealthInput): boolean { * - "Unresponsive" — heartbeat exceeded the configured interval's 4× grace window * * @param agent - The agent object (partial Agent shape is accepted) + * @param heartbeatMultiplier - Project-resolved heartbeat interval multiplier + * @param dataAsOfMs - When this agent record was last confirmed fresh by the server (SWR envelope + * `savedAt` for a hydrated snapshot, fetch time for live data). Omit only for provably-live data. * @returns A health status object with label, icon, color, and stateDerived metadata */ function getHeartbeatRepairMetadata(agent: AgentHealthInput): { @@ -139,6 +143,7 @@ function getHeartbeatRepairMetadata(agent: AgentHealthInput): { export function getAgentHealthStatus( agent: AgentHealthInput, heartbeatMultiplier: number = 1, + dataAsOfMs?: number, ): AgentHealthStatus { const { state, lastHeartbeatAt, lastError, pauseReason, runtimeConfig } = agent; const isTaskWorker = isTaskWorkerAgent(agent); @@ -231,7 +236,16 @@ export function getAgentHealthStatus( }; } - const elapsed = Math.max(0, Date.now() - lastHeartbeat); + /* + FNXC:MobileTabDiscard 2026-07-26-10:16: + Heartbeat freshness measures the heartbeat against the AGE OF THE AGENT RECORD, not wall-clock now. + After a mobile tab discard the agents list hydrates from an SWR snapshot that can be hours old; aging + its heartbeats against `Date.now()` labelled every healthy agent "Unresponsive" (and printed a + fabricated "No heartbeat for 2h") until revalidation landed — seconds on a waking mobile radio. Same + defect that made every in-progress card render "stuck"; see utils/dataFreshness.ts. + `dataAsOfMs === undefined` keeps the previous `Date.now()` behavior for live data. + */ + const elapsed = elapsedSinceMs(lastHeartbeat, dataAsOfMs); if (elapsed > stalenessThresholdMs) { const reason = `No heartbeat for ${formatDuration(elapsed)} (threshold: ${formatDuration(stalenessThresholdMs)})`; @@ -256,8 +270,8 @@ export function getAgentHealthStatus( * Returns a CSS variable name for the health color. * Useful when you need the raw CSS variable name for custom styling. */ -export function getAgentHealthColorVar(agent: AgentHealthInput): string { - const status = getAgentHealthStatus(agent); +export function getAgentHealthColorVar(agent: AgentHealthInput, dataAsOfMs?: number): string { + const status = getAgentHealthStatus(agent, 1, dataAsOfMs); // Extract the CSS variable name from the color string // e.g., "var(--state-error-text)" -> "--state-error-text" const match = status.color.match(/var\((--[^)]+)\)/); diff --git a/packages/dashboard/app/utils/dataFreshness.ts b/packages/dashboard/app/utils/dataFreshness.ts new file mode 100644 index 0000000000..c100597f3c --- /dev/null +++ b/packages/dashboard/app/utils/dataFreshness.ts @@ -0,0 +1,49 @@ +/* +FNXC:MobileTabDiscard 2026-07-26-10:16: +Single source of the `dataAsOfMs` contract. Any "is this thing stale / stuck / unresponsive" verdict +computed in the client must measure a server timestamp against the AGE OF THE DATA IT CAME WITH, not +against wall-clock now. + +Why this module exists rather than an inline `dataAsOfMs ?? Date.now()` at each call site: raising the +SWR hydration TTL means a mobile tab discard can restore a board from a snapshot that is hours old. +Every such verdict computed against `Date.now()` then reads uniformly overdue — every in-progress task +"stuck" (fixed in taskStuck.ts), every agent "Unresponsive" (fixed in agentHealth.tsx). That is one +defect class, found on three surfaces, because the clock choice was re-decided at each call site and +was silently omissible. + +The ratchet: `dataAsOfMs` is a REQUIRED positional parameter here, typed `number | undefined`. A caller +may still pass `undefined` (no snapshot hydrated -> now really is the data's age), but it cannot forget +to decide — TypeScript rejects the two-argument call. Keep it required; the omissibility was the bug. +*/ + +/** + * Milliseconds elapsed between `timestampMs` and the moment the containing data was last confirmed + * fresh by the server. + * + * @param timestampMs - Server-provided instant being aged (epoch ms). `NaN` propagates so callers can + * detect an unparseable timestamp instead of receiving a plausible-looking `0`. + * @param dataAsOfMs - When the record carrying `timestampMs` was last confirmed fresh. Pass the SWR + * envelope's `savedAt` for hydrated snapshots, the fetch time for live data, and `undefined` only + * when the data provably came from the current wall-clock moment (falls back to `Date.now()`). + * + * Clamped at zero: a timestamp newer than its own snapshot is clock skew or an optimistic local write, + * never negative age. + */ +export function elapsedSinceMs(timestampMs: number, dataAsOfMs: number | undefined): number { + return Math.max(0, (dataAsOfMs ?? Date.now()) - timestampMs); +} + +/** + * True when `timestampMs` is older than `thresholdMs` relative to the age of the data it arrived with. + * + * Returns false for an unparseable (`NaN`) `timestampMs` — absence of proof of staleness is not proof + * of staleness. Surfaces that must treat an invalid timestamp as a failure (agentHealth does) should + * check `Number.isFinite` themselves before calling. + */ +export function isOverdue( + timestampMs: number, + thresholdMs: number, + dataAsOfMs: number | undefined, +): boolean { + return elapsedSinceMs(timestampMs, dataAsOfMs) > thresholdMs; +} diff --git a/packages/dashboard/app/utils/swrCache.ts b/packages/dashboard/app/utils/swrCache.ts index df13a27317..eccf55b92c 100644 --- a/packages/dashboard/app/utils/swrCache.ts +++ b/packages/dashboard/app/utils/swrCache.ts @@ -326,7 +326,8 @@ export function pruneStaleCacheEntries(): number { /** * FNXC:SwrCache 2026-07-02-00:00: * User-facing "Clear local data" helper: removes all Fusion-owned browser data — SWR - * hydration caches plus per-project scoped preferences and global UI preferences — while + * hydration caches plus per-project scoped preferences and global UI preferences, and (since + * 2026-07-26-18:05) service-worker Cache Storage, which this function did NOT touch before — while * preserving the dashboard auth token so a reload keeps the session usable. Wired to * Settings → General "Clear local data" as the escape hatch for quota exhaustion. Callers * should reload the page after this so React state re-hydrates from a clean slate. @@ -344,7 +345,83 @@ function isFusionOwnedKey(key: string): boolean { ); } +/* +FNXC:SwrCache 2026-07-26-18:05: +`clearAllLocalCache` walked localStorage ONLY, and nothing else in the dashboard ever touched the +`caches` API. Settings -> "Clear all cached data" therefore left every service-worker-cached response in +durable origin storage — including, before the sw.js allow-list landed, GET /api/settings bodies with +plaintext `daemonToken`/`githubAuthToken`/`gitlabAuthToken`/`ntfyAccessToken`. The operator's only purge +affordance silently did not reach the storage most worth purging. + +Two delete paths, because each covers the other's blind spot: +- postMessage to the controlling service worker: the caller reloads the page immediately after this + returns, which can abort an in-page delete mid-flight; the worker is not torn down by that reload. +- a direct `caches` walk from the page: covers a page with no controlling worker (first load, dev + server, SW unregistered), where the message goes nowhere. +Both are idempotent, so running both is harmless. + +Deliberately fire-and-forget: `clearAllLocalCache` is called synchronously from a click handler that +reloads on return, and its `number` result is the localStorage count that existing callers and tests +already consume. `purgeCacheStorage` is exported separately so the deletion is directly assertable. +*/ +export const PURGE_CACHES_MESSAGE = "PURGE_CACHES"; + +function getCacheStorage(): CacheStorage | null { + try { + if (typeof caches !== "undefined" && caches && typeof caches.keys === "function") { + return caches; + } + } catch { + // Accessing `caches` throws in some non-secure contexts. + } + return null; +} + +/** Deletes every Cache Storage bucket on this origin. Returns how many were deleted. */ +export async function purgeCacheStorage(): Promise { + const cacheStorage = getCacheStorage(); + if (!cacheStorage) { + return 0; + } + + try { + const keys = await cacheStorage.keys(); + const results = await Promise.all( + keys.map(async (key) => { + try { + return await cacheStorage.delete(key); + } catch { + return false; + } + }), + ); + return results.filter(Boolean).length; + } catch { + return 0; + } +} + +/** Asks the controlling service worker to purge Cache Storage; no-op when nothing controls this page. */ +export function requestServiceWorkerCachePurge(): boolean { + try { + const controller = + typeof navigator !== "undefined" && navigator.serviceWorker + ? navigator.serviceWorker.controller + : null; + if (!controller) { + return false; + } + controller.postMessage({ type: PURGE_CACHES_MESSAGE }); + return true; + } catch { + return false; + } +} + export function clearAllLocalCache(): number { + requestServiceWorkerCachePurge(); + void purgeCacheStorage(); + const storage = getLocalStorage(); if (!storage) { return 0; diff --git a/packages/dashboard/app/utils/taskStuck.ts b/packages/dashboard/app/utils/taskStuck.ts index 1d1fdc09cb..62f330e770 100644 --- a/packages/dashboard/app/utils/taskStuck.ts +++ b/packages/dashboard/app/utils/taskStuck.ts @@ -1,4 +1,5 @@ import type { Task } from "@fusion/core"; +import { isOverdue } from "./dataFreshness"; const NON_STUCK_STATUSES = new Set(["failed", "stuck-killed"]); @@ -32,10 +33,15 @@ export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined, return false; } + /* + FNXC:MobileTabDiscard 2026-07-26-10:16: + The clock choice moved to `isOverdue` (utils/dataFreshness.ts) so the same rule serves every + staleness verdict in the client and `dataAsOfMs` cannot be omitted at a call site. Behavior is + unchanged: `dataAsOfMs ?? Date.now()`, strict `>` against a threshold already proven positive above, + and an unparseable `updatedAt` (NaN) still yields false. + */ const updatedAt = new Date(task.updatedAt).getTime(); - // Use dataAsOfMs if provided, otherwise fall back to current time - const now = dataAsOfMs ?? Date.now(); - return now - updatedAt > taskStuckTimeoutMs; + return isOverdue(updatedAt, taskStuckTimeoutMs, dataAsOfMs); } /**