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
+18 -8
View File
@@ -12,7 +12,8 @@ import {
processTimelineResponse,
type ProcessTimelineResponseOutput,
type TimelineReducerSideEffect,
} from "@/contexts/session-stream-reducers";
} from "@/timeline/session-stream-reducers";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type {
AgentAttachment,
AgentStreamEventPayload,
@@ -451,6 +452,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
const markAgentHistorySynchronized = useSessionStore(
@@ -748,17 +750,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (isNative) {
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
if (agentId && cursor) {
if (agentId) {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: 0,
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
console.warn("[Session] failed to fetch tail timeline on resume", agentId, error);
});
}
}
@@ -1024,7 +1024,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: 0,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
@@ -1055,6 +1055,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
setAgentTimelineHasOlder(serverId, (prev) => {
if (prev.get(agentId) === payload.hasOlder) {
return prev;
}
const next = new Map(prev);
next.set(agentId, payload.hasOlder);
return next;
});
if (payload.agent) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot({
@@ -1127,6 +1136,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentStreamHead,
setAgentStreamTail,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
],
);