fix(app): paginate canonical timeline catch-up to avoid relay 1009 disconnect loop (#657)
@fireblue diagnosed the production failure mode: long canonical timeline catch-up could exceed the relay WebSocket frame cap, close with 1009, and send clients into a reconnect loop. Their seatbelt fix also removed the `limit: 0` footgun that let app callers request an unbounded frame. This maintainer pass keeps that diagnosis and redirects the UX around bounded projected pages: - initial agent load fetches the latest canonical tail - app resume/reconnect re-fetches the latest tail instead of chaining after-cursor pages - in-stream gaps still use bounded after-cursor catch-up - older history loads explicitly when the user scrolls to the oldest edge - shared page size is TIMELINE_FETCH_PAGE_SIZE = 100 projected timeline items, matching the daemon's canonical projection semantics rather than raw delta chunks Original diagnosis and fix by @fireblue. Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
906205f97a
commit
654439005a
@@ -1,71 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
// @vitest-environment jsdom
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
|
||||
import { useAgentInitialization } from "./use-agent-initialization";
|
||||
|
||||
const serverId = "server-1";
|
||||
const agentId = "agent-1";
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
fetchAgentTimeline: vi.fn().mockResolvedValue(undefined),
|
||||
refreshAgent: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resolveInitDeferred(getInitKey(serverId, agentId));
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useAgentInitialization", () => {
|
||||
it("always requests a canonical tail bootstrap even with an authoritative cursor", () => {
|
||||
const client = makeClient();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentTimelineCursor(
|
||||
serverId,
|
||||
new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]),
|
||||
);
|
||||
useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentInitialization({ serverId, client: client as never }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void result.current.ensureAgentIsInitialized(agentId);
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
it("refresh fetches a bounded canonical tail after refreshing the agent", async () => {
|
||||
const client = makeClient();
|
||||
const { result } = renderHook(() =>
|
||||
useAgentInitialization({ serverId, client: client as never }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshAgent(agentId);
|
||||
});
|
||||
|
||||
expect(client.refreshAgent).toHaveBeenCalledWith(agentId);
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports unbounded tail bootstrap policy", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose an RPC-success init fallback", () => {
|
||||
expect("shouldResolveInitFromRpcSuccess" in __private__).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,21 +8,9 @@ import {
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
||||
|
||||
function resolveInitialTimelineLimit(): number {
|
||||
return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
deriveInitialTimelineRequest,
|
||||
resolveInitialTimelineLimit,
|
||||
};
|
||||
|
||||
export function useAgentInitialization({
|
||||
serverId,
|
||||
@@ -54,19 +42,13 @@ export function useAgentInitialization({
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
const hasAuthoritativeHistory =
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
const initRequestDirection = timelineRequest.direction === "after" ? "after" : "tail";
|
||||
const timelineRequest = {
|
||||
direction: "tail" as const,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical" as const,
|
||||
};
|
||||
|
||||
const deferred = createInitDeferred(key, initRequestDirection);
|
||||
const deferred = createInitDeferred(key, "tail");
|
||||
const timeoutId = setTimeout(() => {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
@@ -103,10 +85,9 @@ export function useAgentInitialization({
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import { useLoadOlderAgentHistory } from "./use-load-older-agent-history";
|
||||
|
||||
const serverId = "server-1";
|
||||
const agentId = "agent-1";
|
||||
|
||||
function makeClient(fetchAgentTimeline = vi.fn().mockResolvedValue(undefined)) {
|
||||
return { fetchAgentTimeline };
|
||||
}
|
||||
|
||||
function initialize(input?: {
|
||||
cursor?: { epoch: string; startSeq: number; endSeq: number };
|
||||
hasOlder?: boolean;
|
||||
inFlight?: boolean;
|
||||
fetchAgentTimeline?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const client = makeClient(input?.fetchAgentTimeline);
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
if (input?.cursor) {
|
||||
useSessionStore.getState().setAgentTimelineCursor(serverId, new Map([[agentId, input.cursor]]));
|
||||
}
|
||||
if (input?.hasOlder !== undefined) {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentTimelineHasOlder(serverId, new Map([[agentId, input.hasOlder]]));
|
||||
}
|
||||
if (input?.inFlight !== undefined) {
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentTimelineOlderFetchInFlight(serverId, new Map([[agentId, input.inFlight]]));
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useLoadOlderAgentHistory", () => {
|
||||
it("no-ops without a cursor", () => {
|
||||
const client = initialize({ hasOlder: true });
|
||||
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
|
||||
|
||||
act(() => {
|
||||
result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no-ops when the daemon says no older history exists", () => {
|
||||
const client = initialize({
|
||||
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
|
||||
hasOlder: false,
|
||||
});
|
||||
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
|
||||
|
||||
act(() => {
|
||||
result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dedupes concurrent older-page requests", () => {
|
||||
const fetchAgentTimeline = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
}),
|
||||
);
|
||||
const client = initialize({
|
||||
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
|
||||
hasOlder: true,
|
||||
fetchAgentTimeline,
|
||||
});
|
||||
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
|
||||
|
||||
act(() => {
|
||||
result.current.loadOlder();
|
||||
result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requests the page before the current start cursor and clears in-flight on success", async () => {
|
||||
const client = initialize({
|
||||
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
|
||||
hasOlder: true,
|
||||
});
|
||||
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
|
||||
|
||||
act(() => {
|
||||
result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: "epoch-1", seq: 10 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a panel toast and clears in-flight on failure", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const error = new Error("network");
|
||||
const client = initialize({
|
||||
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
|
||||
hasOlder: true,
|
||||
fetchAgentTimeline: vi.fn().mockRejectedValue(error),
|
||||
});
|
||||
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
|
||||
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId, toast }));
|
||||
|
||||
act(() => {
|
||||
result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => {
|
||||
expect(toast.show).toHaveBeenCalledWith("Couldn't load older history", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-load-older-history-toast",
|
||||
});
|
||||
});
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useCallback } from "react";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
|
||||
export function useLoadOlderAgentHistory({
|
||||
serverId,
|
||||
agentId,
|
||||
toast,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
toast?: ToastApi | null;
|
||||
}) {
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const cursor = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agentTimelineCursor.get(agentId),
|
||||
);
|
||||
const hasOlder =
|
||||
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
|
||||
true;
|
||||
const isLoadingOlder =
|
||||
useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
|
||||
) === true;
|
||||
const setOlderFetchInFlight = useSessionStore(
|
||||
(state) => state.setAgentTimelineOlderFetchInFlight,
|
||||
);
|
||||
|
||||
const setInFlight = useCallback(
|
||||
(value: boolean) => {
|
||||
setOlderFetchInFlight(serverId, (prev) => {
|
||||
if (prev.get(agentId) === value) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, value);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[agentId, serverId, setOlderFetchInFlight],
|
||||
);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
const latestSession = useSessionStore.getState().sessions[serverId];
|
||||
const latestIsLoading = latestSession?.agentTimelineOlderFetchInFlight.get(agentId) === true;
|
||||
if (!client || !cursor || !hasOlder || isLoadingOlder || latestIsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInFlight(true);
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "before",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.startSeq },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Timeline] failed to load older agent history", agentId, error);
|
||||
toast?.show("Couldn't load older history", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-load-older-history-toast",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setInFlight(false);
|
||||
});
|
||||
}, [agentId, client, cursor, hasOlder, isLoadingOlder, serverId, setInFlight, toast]);
|
||||
|
||||
return {
|
||||
isLoadingOlder,
|
||||
hasOlder,
|
||||
loadOlder,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user