From 907f9c51725954d1e60334ecc4ea6154f3214f27 Mon Sep 17 00:00:00 2001 From: GautamTalksDev Date: Sun, 30 Aug 2026 00:54:39 -0400 Subject: [PATCH 1/2] fix(ui): ignore stale scan card refreshes Abort prior card requests and require responses to match the active scan so old cards and terminal status cannot overwrite a new scan or its headline. Co-authored-by: Cursor --- apps/web/src/api/client.ts | 7 ++- apps/web/src/hooks/useScanSession.test.ts | 50 +++++++++++++++++++++- apps/web/src/hooks/useScanSession.ts | 52 +++++++++++++++++------ 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d7a80c4..807f188 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -42,7 +42,10 @@ export async function startScan(input: { }); } -export async function fetchCards(scanId: string): Promise<{ +export async function fetchCards( + scanId: string, + signal?: AbortSignal, +): Promise<{ scanId: string; status: string; cards: ApiCard[]; @@ -50,7 +53,7 @@ export async function fetchCards(scanId: string): Promise<{ driver?: string | null; recordingId?: string | null; }> { - return request(`/scans/${scanId}/cards`); + return request(`/scans/${scanId}/cards`, { signal }); } export async function postDecision( diff --git a/apps/web/src/hooks/useScanSession.test.ts b/apps/web/src/hooks/useScanSession.test.ts index 881c4dc..4d92de1 100644 --- a/apps/web/src/hooks/useScanSession.test.ts +++ b/apps/web/src/hooks/useScanSession.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import { applyEvent, emptyActivity } from "./useScanSession.js"; +import type { ApiCard } from "../api/types.js"; +import { applyEvent, emptyActivity, reduce, type ScanSessionState } from "./useScanSession.js"; const event = (type: string, systemId?: string) => ({ type, @@ -34,4 +35,51 @@ describe("scan session activity reducer", () => { expect(afterReconcile.subagents["failed-system"]?.status).toBe("failed"); expect(afterReconcile.subagents["healthy-system"]?.status).toBe("done"); }); + + it("discards a previous scan's card refresh after a new scan starts", () => { + const oldCard = { id: "old-card" } as ApiCard; + const newCard = { id: "new-card" } as ApiCard; + let state: ScanSessionState = { + activity: emptyActivity(), + cards: [] as ApiCard[], + loading: false, + error: null, + }; + + state = reduce(state, { type: "scan_starting", person: "Ada Lovelace" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-1", + person: "Ada Lovelace", + }); + const staleRefresh = { + type: "cards" as const, + scanId: "scan-1", + cards: [oldCard], + status: "completed", + }; + state = reduce(state, { type: "scan_starting", person: "Grace Hopper" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-2", + person: "Grace Hopper", + }); + + const staleResult = reduce(state, staleRefresh); + expect(staleResult.cards).toEqual([]); + expect(staleResult.activity.status).toBe("running"); + + const currentResult = reduce(staleResult, { + type: "cards", + scanId: "scan-2", + cards: [newCard], + status: "completed", + }); + expect(currentResult.cards).toEqual([newCard]); + expect(currentResult.activity.status).toBe("completed"); + + const lateStaleResult = reduce(currentResult, staleRefresh); + expect(lateStaleResult.cards).toEqual([newCard]); + expect(lateStaleResult.activity.status).toBe("completed"); + }); }); diff --git a/apps/web/src/hooks/useScanSession.ts b/apps/web/src/hooks/useScanSession.ts index 7ca2e4b..6f6027f 100644 --- a/apps/web/src/hooks/useScanSession.ts +++ b/apps/web/src/hooks/useScanSession.ts @@ -9,7 +9,7 @@ import type { } from "../api/types.js"; import { classifyClientError, recoveryFor } from "../lib/errors.js"; -type State = { +export type ScanSessionState = { activity: AgentActivityState; cards: ApiCard[]; loading: boolean; @@ -31,6 +31,7 @@ type Action = | { type: "event"; event: ScanProgressEvent } | { type: "cards"; + scanId: string; cards: ApiCard[]; status: string; costs?: AgentActivityState["costs"]; @@ -67,7 +68,7 @@ function pushLog( }; } -function reduce(state: State, action: Action): State { +export function reduce(state: ScanSessionState, action: Action): ScanSessionState { switch (action.type) { case "reset": return { activity: emptyActivity(), cards: [], loading: false, error: null }; @@ -130,6 +131,7 @@ function reduce(state: State, action: Action): State { }; } case "cards": + if (state.activity.scanId !== action.scanId) return state; return { ...state, cards: action.cards, @@ -461,25 +463,50 @@ export function useScanSession() { error: null, }); const unsubRef = useRef<(() => void) | null>(null); + const activeScanIdRef = useRef(null); + const refreshAbortRef = useRef(null); useEffect(() => { - return () => unsubRef.current?.(); + return () => { + unsubRef.current?.(); + refreshAbortRef.current?.abort(); + activeScanIdRef.current = null; + }; }, []); async function refreshCards(scanId: string) { - const res = await fetchCards(scanId); - dispatch({ - type: "cards", - cards: res.cards, - status: res.status, - costs: res.costs ?? null, - driver: (res.driver as string | null) ?? null, - recordingId: (res.recordingId as string | null) ?? null, - }); + refreshAbortRef.current?.abort(); + const controller = new AbortController(); + refreshAbortRef.current = controller; + try { + const res = await fetchCards(scanId, controller.signal); + if (activeScanIdRef.current !== scanId || controller.signal.aborted) return; + dispatch({ + type: "cards", + scanId, + cards: res.cards, + status: res.status, + costs: res.costs ?? null, + driver: (res.driver as string | null) ?? null, + recordingId: (res.recordingId as string | null) ?? null, + }); + } catch (err) { + if (!controller.signal.aborted && activeScanIdRef.current === scanId) { + throw err; + } + } finally { + if (refreshAbortRef.current === controller) { + refreshAbortRef.current = null; + } + } } async function beginScan(person: string) { unsubRef.current?.(); + unsubRef.current = null; + refreshAbortRef.current?.abort(); + refreshAbortRef.current = null; + activeScanIdRef.current = null; dispatch({ type: "scan_starting", person }); try { const started = await startScan({ person }); @@ -490,6 +517,7 @@ export function useScanSession() { driver: started.driver, recordingId: started.recordingId ?? null, }); + activeScanIdRef.current = started.scanId; unsubRef.current = subscribeScanStream(started.scanId, { onEvent: (event) => { dispatch({ type: "event", event }); From 93c188a5ee1eec89b698df2f322ade328f163903 Mon Sep 17 00:00:00 2001 From: GautamTalksDev Date: Sun, 30 Aug 2026 01:00:03 -0400 Subject: [PATCH 2/2] fix(ui): guard superseded scan starts Use a monotonic scan-start token to prevent late start responses from replacing the active scan or installing stale SSE subscriptions, keeping queue updates tied to the latest invocation. Co-authored-by: Cursor --- apps/web/src/hooks/useScanSession.test.ts | 70 +++++++++++++++++++- apps/web/src/hooks/useScanSession.ts | 79 +++++++++++++++++++---- 2 files changed, 136 insertions(+), 13 deletions(-) diff --git a/apps/web/src/hooks/useScanSession.test.ts b/apps/web/src/hooks/useScanSession.test.ts index 4d92de1..28670ad 100644 --- a/apps/web/src/hooks/useScanSession.test.ts +++ b/apps/web/src/hooks/useScanSession.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import type { ApiCard } from "../api/types.js"; -import { applyEvent, emptyActivity, reduce, type ScanSessionState } from "./useScanSession.js"; +import { + applyEvent, + createScanStartCoordinator, + emptyActivity, + reduce, + type ScanSessionState, +} from "./useScanSession.js"; const event = (type: string, systemId?: string) => ({ type, @@ -82,4 +88,66 @@ describe("scan session activity reducer", () => { expect(lateStaleResult.cards).toEqual([newCard]); expect(lateStaleResult.activity.status).toBe("completed"); }); + + it("lets only the latest scan start own the SSE subscription and queue", () => { + const coordinator = createScanStartCoordinator(); + const existingUnsubscribe = { called: false }; + const existingToken = coordinator.begin(); + expect( + coordinator.commit(existingToken, "scan-0", () => { + existingUnsubscribe.called = true; + }), + ).toBe(true); + + const firstToken = coordinator.begin(); + const secondToken = coordinator.begin(); + const secondUnsubscribe = { called: false }; + expect( + coordinator.commit(secondToken, "scan-2", () => { + secondUnsubscribe.called = true; + }), + ).toBe(true); + expect(existingUnsubscribe.called).toBe(true); + expect(coordinator.activeScanId).toBe("scan-2"); + expect(coordinator.hasSubscription).toBe(true); + + const staleUnsubscribe = { called: false }; + expect( + coordinator.commit(firstToken, "scan-1", () => { + staleUnsubscribe.called = true; + }), + ).toBe(false); + expect(staleUnsubscribe.called).toBe(true); + expect(coordinator.activeScanId).toBe("scan-2"); + expect(secondUnsubscribe.called).toBe(false); + + let state: ScanSessionState = { + activity: emptyActivity(), + cards: [], + loading: false, + error: null, + }; + state = reduce(state, { type: "scan_starting", person: "Ada Lovelace" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-1", + person: "Ada Lovelace", + }); + state = reduce(state, { type: "scan_starting", person: "Grace Hopper" }); + state = reduce(state, { + type: "scan_started", + scanId: "scan-2", + person: "Grace Hopper", + }); + state = reduce(state, { + type: "cards", + scanId: "scan-1", + cards: [{ id: "stale" } as ApiCard], + status: "completed", + }); + + expect(state.activity.scanId).toBe("scan-2"); + expect(state.cards).toEqual([]); + expect(state.activity.status).toBe("running"); + }); }); diff --git a/apps/web/src/hooks/useScanSession.ts b/apps/web/src/hooks/useScanSession.ts index 6f6027f..94154d6 100644 --- a/apps/web/src/hooks/useScanSession.ts +++ b/apps/web/src/hooks/useScanSession.ts @@ -40,6 +40,55 @@ type Action = } | { type: "card_updated"; card: ApiCard }; +export interface ScanStartCoordinator { + begin(): number; + canCommit(token: number): boolean; + commit(token: number, scanId: string, unsubscribe: () => void): boolean; + cancel(): void; + readonly activeScanId: string | null; + readonly hasSubscription: boolean; +} + +export function createScanStartCoordinator(): ScanStartCoordinator { + let latestToken = 0; + let activeScanId: string | null = null; + let unsubscribe: (() => void) | null = null; + + return { + begin() { + latestToken += 1; + unsubscribe?.(); + unsubscribe = null; + activeScanId = null; + return latestToken; + }, + canCommit(token) { + return token === latestToken; + }, + commit(token, scanId, nextUnsubscribe) { + if (token !== latestToken) { + nextUnsubscribe(); + return false; + } + activeScanId = scanId; + unsubscribe = nextUnsubscribe; + return true; + }, + cancel() { + latestToken += 1; + unsubscribe?.(); + unsubscribe = null; + activeScanId = null; + }, + get activeScanId() { + return activeScanId; + }, + get hasSubscription() { + return unsubscribe !== null; + }, + }; +} + export const emptyActivity = (): AgentActivityState => ({ scanId: null, status: "idle", @@ -462,17 +511,19 @@ export function useScanSession() { loading: false, error: null, }); - const unsubRef = useRef<(() => void) | null>(null); - const activeScanIdRef = useRef(null); + const scanStartCoordinatorRef = useRef(null); const refreshAbortRef = useRef(null); + if (scanStartCoordinatorRef.current === null) { + scanStartCoordinatorRef.current = createScanStartCoordinator(); + } + const scanStartCoordinator = scanStartCoordinatorRef.current; useEffect(() => { return () => { - unsubRef.current?.(); + scanStartCoordinator.cancel(); refreshAbortRef.current?.abort(); - activeScanIdRef.current = null; }; - }, []); + }, [scanStartCoordinator]); async function refreshCards(scanId: string) { refreshAbortRef.current?.abort(); @@ -480,7 +531,9 @@ export function useScanSession() { refreshAbortRef.current = controller; try { const res = await fetchCards(scanId, controller.signal); - if (activeScanIdRef.current !== scanId || controller.signal.aborted) return; + if (scanStartCoordinator.activeScanId !== scanId || controller.signal.aborted) { + return; + } dispatch({ type: "cards", scanId, @@ -491,7 +544,7 @@ export function useScanSession() { recordingId: (res.recordingId as string | null) ?? null, }); } catch (err) { - if (!controller.signal.aborted && activeScanIdRef.current === scanId) { + if (!controller.signal.aborted && scanStartCoordinator.activeScanId === scanId) { throw err; } } finally { @@ -502,14 +555,13 @@ export function useScanSession() { } async function beginScan(person: string) { - unsubRef.current?.(); - unsubRef.current = null; + const startToken = scanStartCoordinator.begin(); refreshAbortRef.current?.abort(); refreshAbortRef.current = null; - activeScanIdRef.current = null; dispatch({ type: "scan_starting", person }); try { const started = await startScan({ person }); + if (!scanStartCoordinator.canCommit(startToken)) return; dispatch({ type: "scan_started", scanId: started.scanId, @@ -517,8 +569,7 @@ export function useScanSession() { driver: started.driver, recordingId: started.recordingId ?? null, }); - activeScanIdRef.current = started.scanId; - unsubRef.current = subscribeScanStream(started.scanId, { + const unsubscribe = subscribeScanStream(started.scanId, { onEvent: (event) => { dispatch({ type: "event", event }); if ( @@ -533,9 +584,13 @@ export function useScanSession() { } }, }); + if (!scanStartCoordinator.commit(startToken, started.scanId, unsubscribe)) { + return; + } // Initial poll in case events already finished void refreshCards(started.scanId); } catch (err) { + if (!scanStartCoordinator.canCommit(startToken)) return; dispatch({ type: "scan_error", error: err instanceof Error ? err.message : String(err),