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
+54
View File
@@ -268,6 +268,8 @@ export interface SessionState {
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
agentAuthoritativeHistoryApplied: Map<string, boolean>;
@@ -351,6 +353,14 @@ interface SessionStoreActions {
| Map<string, AgentTimelineCursorState>
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>),
) => void;
setAgentTimelineHasOlder: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
setAgentTimelineOlderFetchInFlight: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
bumpHistorySyncGeneration: (serverId: string) => void;
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
setAgentAuthoritativeHistoryApplied: (
@@ -443,6 +453,8 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
agentStreamTail: new Map(),
agentStreamHead: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
historySyncGeneration: 0,
agentHistorySyncGeneration: new Map(),
agentAuthoritativeHistoryApplied: new Map(),
@@ -861,6 +873,48 @@ export const useSessionStore = create<SessionStore>()(
});
},
setAgentTimelineHasOlder: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineHasOlder) : state;
if (session.agentTimelineHasOlder === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineHasOlder: nextState },
},
};
});
},
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineOlderFetchInFlight) : state;
if (session.agentTimelineOlderFetchInFlight === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineOlderFetchInFlight: nextState },
},
};
});
},
bumpHistorySyncGeneration: (serverId) => {
set((prev) => {
const session = prev.sessions[serverId];