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:
fireblue
2026-05-02 19:53:50 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 906205f97a
commit 654439005a
18 changed files with 728 additions and 185 deletions
@@ -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);
});
});