From 3f63df678acbd6fb89415e488f41720d2162b2cb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 11:36:31 +0200 Subject: [PATCH] fix(timeline): resume agents from the latest history Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination. --- docs/timeline-sync.md | 31 ++- .../app/e2e/agent-timeline-resume.spec.ts | 65 ++++++ .../app/e2e/helpers/daemon-websocket-gate.ts | 14 ++ .../app/e2e/helpers/timeline-pagination.ts | 47 +++- packages/app/e2e/helpers/timeline-resume.ts | 68 ++++++ packages/app/src/contexts/session-context.tsx | 41 ++-- .../hooks/use-agent-initialization.test.ts | 7 +- .../app/src/hooks/use-agent-initialization.ts | 10 +- packages/app/src/stores/session-store.test.ts | 31 +++ packages/app/src/stores/session-store.ts | 6 +- .../timeline/session-stream-reducers.test.ts | 213 ++++++++++++++++++ .../src/timeline/session-stream-reducers.ts | 116 ++++++++-- .../src/timeline/timeline-sync-plan.test.ts | 47 +--- .../app/src/timeline/timeline-sync-plan.ts | 27 --- .../src/timeline/viewed-timeline-sync.test.ts | 43 ++-- .../app/src/timeline/viewed-timeline-sync.ts | 53 +++-- 16 files changed, 654 insertions(+), 165 deletions(-) create mode 100644 packages/app/e2e/agent-timeline-resume.spec.ts create mode 100644 packages/app/e2e/helpers/timeline-resume.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index a56fa3cc83..e79b9dd4eb 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -5,9 +5,11 @@ Agent chat delivery has two paths: 1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates. 2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas. -The invariant is: +The invariants are: -> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail. +> A continuously subscribed client applies every committed row in order. Opening or resuming an +> agent establishes the daemon's current tail in one bounded request, with older history reachable +> through backward pagination. Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events. @@ -25,17 +27,20 @@ Client heartbeat reports presence: Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream. -## Catch-up is paged but complete +## Gap recovery is paged but complete Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial. Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows. -When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`. +When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon +responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap +recovery is complete only when `hasNewer: false`. Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog. -The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. +Opening or resuming an agent fetches one bounded latest tail page. Older history remains +user-driven by scrolling upward. Reaching the history-start threshold loads one older page and preserves the visible content anchor. Cursor progress does not trigger another page. The user must leave and return to the threshold unless @@ -53,9 +58,21 @@ The daemon validates that the epoch is current and the exact source sequence sti ## Resume behavior -When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run. +Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page. +Focus alone does not mutate timeline state; the tail response is compared with the local +authoritative range first. -When a client resumes without a cursor, it fetches the latest tail page. +- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization + bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport. +- When the page overlaps or is adjacent to the local end cursor, only projected items newer than + that cursor are applied. Already-covered rows are not replayed. +- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the + latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved + local submissions; it never retains two discontiguous canonical ranges. + +The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through +ordinary backward pagination. A backward page is accepted only when it is adjacent to the current +history start; a response requested from a pre-replacement range is stale and is discarded. ## Client replica lifetime diff --git a/packages/app/e2e/agent-timeline-resume.spec.ts b/packages/app/e2e/agent-timeline-resume.spec.ts new file mode 100644 index 0000000000..e21e3a1867 --- /dev/null +++ b/packages/app/e2e/agent-timeline-resume.spec.ts @@ -0,0 +1,65 @@ +import { test } from "./fixtures"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectTimelinePresentationUnchanged, + expectTimelinePromptNotMounted, + expectTimelinePromptVisible, + openAgentTimeline, + rememberTimelinePresentation, + scrollTimelinePromptIntoView, + scrollTimelineUntilOlderHistoryIsReachable, + seedLongMockAgentTimeline, +} from "./helpers/timeline-pagination"; +import { + commitTimelineTurnsWhileDisconnected, + disconnectViewedTimeline, + expectOneTailCheckWithoutCatchUpPages, + rememberTimelineRequestCounts, + restoreViewedTimelineWithHeldTail, +} from "./helpers/timeline-resume"; + +test.describe("Agent timeline resume", () => { + test("a long disconnected run lands as one latest-tail update", async ({ page }) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await seedLongMockAgentTimeline({ turns: 3 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + const requests = rememberTimelineRequestCounts(gate); + await disconnectViewedTimeline(page, gate); + const background = await commitTimelineTurnsWhileDisconnected(agent, 24); + await restoreViewedTimelineWithHeldTail(page, gate); + + await expectTimelinePromptVisible(page, background.newestPrompt); + await expectTimelinePromptNotMounted(page, background.firstPrompt); + expectOneTailCheckWithoutCatchUpPages(gate, requests); + await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + + test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await seedLongMockAgentTimeline({ turns: 30 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt); + const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt); + const requests = rememberTimelineRequestCounts(gate); + + await disconnectViewedTimeline(page, gate); + await restoreViewedTimelineWithHeldTail(page, gate); + + await expectTimelinePresentationUnchanged(page, presentation); + expectOneTailCheckWithoutCatchUpPages(gate, requests); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index 0ab8144f3b..091d8f0b39 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -14,6 +14,7 @@ export interface DirectoryRequestStartCounts { interface ClientRequest { type?: unknown; + direction?: unknown; subscribe?: unknown; page?: { cursor?: unknown }; payload?: unknown; @@ -181,6 +182,7 @@ export async function installDaemonWebSocketGate(page: Page) { total: { agents: 0, workspaces: 0 }, }; const clientRequestCounts = new Map(); + const timelineRequestCounts = new Map(); const serverMessageCounts = new Map(); const agentStreamItemCounts = new Map(); const serverMessageWaiters = new Set<() => void>(); @@ -210,6 +212,15 @@ export async function installDaemonWebSocketGate(page: Page) { const request = readClientRequest(message); if (typeof request?.type === "string") { clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1); + if ( + request.type === "fetch_agent_timeline_request" && + typeof request.direction === "string" + ) { + timelineRequestCounts.set( + request.direction, + (timelineRequestCounts.get(request.direction) ?? 0) + 1, + ); + } const directory = directoryForRequest(request); if (directory) { const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed"; @@ -445,6 +456,9 @@ export async function installDaemonWebSocketGate(page: Page) { getClientRequestCount(type: string): number { return clientRequestCounts.get(type) ?? 0; }, + getTimelineRequestCount(direction: "tail" | "before" | "after"): number { + return timelineRequestCounts.get(direction) ?? 0; + }, getAgentStreamItemCount(type: string): number { return agentStreamItemCounts.get(type) ?? 0; }, diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index 03f0f51d0b..2b00a980ba 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -16,8 +16,9 @@ interface LongTimelineAgentOptions { turns: number; } -interface LongTimelineAgent extends MockAgentWorkspace { +export interface LongTimelineAgent extends MockAgentWorkspace { firstOlderPagePrompt: string; + initialTailAnchorPrompt: string; initialTailOldestPrompt: string; oldestPrompt: string; newestPrompt: string; @@ -36,6 +37,12 @@ interface TimelineViewportSnapshot { scrollTop: number; } +export interface TimelinePresentationSnapshot { + marker: string; + position: TimelinePromptPositionSnapshot; + viewport: TimelineViewportSnapshot; +} + interface TimelinePromptPositionSnapshot { prompt: string; top: number; @@ -74,6 +81,7 @@ export async function seedLongMockAgentTimeline( return { ...agent, firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)), + initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)), initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)), oldestPrompt: promptForTurn(0), newestPrompt: promptForTurn(options.turns - 1), @@ -278,6 +286,43 @@ export async function rememberTimelineViewport(page: Page): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const row = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await row.scrollIntoViewIfNeeded(); + await expect(row).toBeVisible(); + await expect + .poll(async () => (await readTimelineViewport(page)).scrollTop) + .toBeGreaterThan(HISTORY_START_THRESHOLD_PX); +} + +export async function rememberTimelinePresentation( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const row = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await expect(row).toBeVisible(); + const marker = `timeline-presentation-${Date.now()}`; + await row.evaluate((element, value) => { + element.dataset.timelinePresentation = value; + }, marker); + return { + marker, + position: await rememberTimelinePromptPosition(page, prompt), + viewport: await rememberTimelineViewport(page), + }; +} + +export async function expectTimelinePresentationUnchanged( + page: Page, + before: TimelinePresentationSnapshot, +): Promise { + await expect(page.locator(`[data-timeline-presentation="${before.marker}"]`)).toBeVisible(); + await expectTimelinePromptPositionPreserved(page, before.position); + await expect.poll(async () => rememberTimelineViewport(page)).toEqual(before.viewport); +} + export async function expectTimelineViewportAnchoredAfterPrepend( page: Page, before: TimelineViewportSnapshot, diff --git a/packages/app/e2e/helpers/timeline-resume.ts b/packages/app/e2e/helpers/timeline-resume.ts new file mode 100644 index 0000000000..c4a44713c7 --- /dev/null +++ b/packages/app/e2e/helpers/timeline-resume.ts @@ -0,0 +1,68 @@ +import { expect, type Page } from "@playwright/test"; +import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; +import type { LongTimelineAgent } from "./timeline-pagination"; +import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui"; + +type DaemonWebSocketGate = Awaited>; + +export interface TimelineRequestCounts { + after: number; + tail: number; +} + +export interface BackgroundTimelineTurns { + firstPrompt: string; + newestPrompt: string; +} + +function backgroundPrompt(index: number): string { + return `timeline-resume-background-${index}: emit 1 coalesced agent stream updates`; +} + +export async function commitTimelineTurnsWhileDisconnected( + agent: LongTimelineAgent, + count: number, +): Promise { + for (let index = 0; index < count; index += 1) { + await agent.client.sendAgentMessage(agent.agentId, backgroundPrompt(index)); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + return { + firstPrompt: backgroundPrompt(0), + newestPrompt: backgroundPrompt(count - 1), + }; +} + +export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): TimelineRequestCounts { + return { + after: gate.getTimelineRequestCount("after"), + tail: gate.getTimelineRequestCount("tail"), + }; +} + +export function expectOneTailCheckWithoutCatchUpPages( + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): void { + expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1); + expect(gate.getTimelineRequestCount("after") - before.after).toBe(0); +} + +export async function disconnectViewedTimeline( + page: Page, + gate: DaemonWebSocketGate, +): Promise { + await gate.drop(); + await expectReconnectingToastVisible(page); +} + +export async function restoreViewedTimelineWithHeldTail( + page: Page, + gate: DaemonWebSocketGate, +): Promise { + gate.holdNextServerMessage("fetch_agent_timeline_response"); + gate.restore(); + await gate.waitForHeldServerMessage(); + gate.releaseHeldServerMessage(); + await expectReconnectingToastGone(page); +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5fd5dcb6eb..43d82e5c27 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -351,6 +351,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); + const markAgentHistorySynchronized = useSessionStore( + (state) => state.markAgentHistorySynchronized, + ); const applyAgentTimelineResponseState = useSessionStore( (state) => state.applyAgentTimelineResponseState, ); @@ -619,14 +622,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } - applyAgentTimelineResponseState(serverId, agentId, { - items: result.tail, - head: result.head, - range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), - older: payload.hasOlder ? "available" : "none", - synchronized: shouldMarkAuthoritativeHistoryApplied, - acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, - }); + if (result.commit === "discard") { + markAgentHistorySynchronized(serverId, agentId); + } else { + applyAgentTimelineResponseState(serverId, agentId, { + items: result.tail, + head: result.head, + range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), + older: result.older, + synchronized: shouldMarkAuthoritativeHistoryApplied, + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + } executeTimelineSideEffects({ sideEffects: result.sideEffects, @@ -643,7 +650,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setInitializingAgents, }); }, - [applyAgentTimelineResponseState, recoverTimelineGap, serverId, setInitializingAgents], + [ + applyAgentTimelineResponseState, + markAgentHistorySynchronized, + recoverTimelineGap, + serverId, + setInitializingAgents, + ], ); useEffect(() => { @@ -654,16 +667,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const sync = createViewedTimelineSync({ initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), - readCursor: (agentId) => { - const timeline = selectAgentTimelineState( - useSessionStore.getState().sessions[serverId], - agentId, - ); - return timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; - }, - hasAuthoritativeHistory: (agentId) => - selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId).status === - "synced", fetchPage: async (agentId, request) => { const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index e78388a908..e3d4fc23b5 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -49,7 +49,7 @@ afterEach(() => { }); describe("ensureAgentIsInitialized", () => { - it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => { + it("requests a bounded projected tail when authoritative history is loaded", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); @@ -74,14 +74,13 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, request: { - direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, + direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", }, }, ]); - expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); + expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); }); it("requests a bounded projected tail when no authoritative cursor is available", () => { diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index ade27abacd..c44621c0f0 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; import { createInitDeferred, getInitDeferred, @@ -11,7 +11,7 @@ import { refreshInitTimeout, } from "@/utils/agent-initialization"; import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; -import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; export type SetAgentInitializing = (agentId: string, initializing: boolean) => void; @@ -51,11 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return existing.promise; } - const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); - const cursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; - const hasAuthoritativeHistory = timeline.status === "synced"; - const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory }); + const timelineRequest = planTimelineTailFetch(); const deferred = createInitDeferred(key, timelineRequest.direction); refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing }); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 68da911630..0f7647057d 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -134,6 +134,37 @@ describe("agent timeline state", () => { ).toEqual({ status: "synced", items: [], range: null, older: "none" }); }); + it("preserves older availability when an applied response leaves it unchanged", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + store.applyAgentTimelineResponseState("test-server", "agent-1", { + items: [], + head: [], + range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, + older: "available", + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + store.applyAgentTimelineResponseState("test-server", "agent-1", { + items: [], + head: [], + range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, + older: "unchanged", + synchronized: false, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual({ + status: "synced", + items: [], + range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, + older: "available", + }); + }); + it("lets a later running directory transition raise but never lower an open turn", () => { initializeTestSession(); const store = useSessionStore.getState(); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 760e703c79..e55908b33e 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -554,7 +554,7 @@ interface SessionStoreActions { items: StreamItem[]; head: StreamItem[]; range: AgentTimelineCursorState | null; - older: "available" | "none"; + older: "available" | "none" | "unchanged"; synchronized: boolean; acknowledgedClientMessageIds: string[]; }, @@ -1519,7 +1519,9 @@ export const useSessionStore = create()( if (state.range) nextCursor.set(agentId, state.range); else nextCursor.delete(agentId); const nextHasOlder = new Map(session.agentTimelineHasOlder); - nextHasOlder.set(agentId, state.older === "available"); + if (state.older !== "unchanged") { + nextHasOlder.set(agentId, state.older === "available"); + } const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied); const nextSyncGeneration = new Map(session.agentHistorySyncGeneration); const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 6503f62c6f..f8744a6ee6 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -197,6 +197,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = { direction: "after", reset: false, epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 0, nextSeq: 1 }, startCursor: null, endCursor: null, entries: [], @@ -228,6 +229,178 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("discards an unchanged resume tail without replacing timeline state", () => { + const currentTail = [makeAssistantItem("existing tail", "existing-tail")]; + const currentHead = [makeAssistantItem("existing head", "existing-head")]; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentHead, + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 40, nextSeq: 41 }, + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + entries: [makeTimelineEntry(1, "existing tail", "assistant_message", 40)], + }, + }); + + expect(result.commit).toBe("discard"); + expect(result.tail).toBe(currentTail); + expect(result.head).toBe(currentHead); + expect(result.cursorChanged).toBe(false); + expect(result.sideEffects).toEqual([{ type: "flush_pending_updates" }]); + }); + + it("appends only rows newer than the cursor from an overlapping resume tail", () => { + const loaded: StreamItem = { + kind: "user_message", + id: "loaded-30", + text: "already loaded", + timestamp: new Date(1030), + timelineCursor: { epoch: "epoch-1", seq: 30 }, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [loaded], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 42, nextSeq: 43 }, + startCursor: { seq: 30 }, + endCursor: { seq: 42 }, + entries: [ + makeTimelineEntry(30, "already loaded", "user_message"), + makeTimelineEntry(41, "new 41", "user_message"), + makeTimelineEntry(42, "new 42", "user_message"), + ], + }, + }); + + expect(result.commit).toBe("apply"); + expect(result.tail[0]).toBe(loaded); + expect(getUserTexts(result.tail)).toEqual(["already loaded", "new 41", "new 42"]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 42 }); + expect(result.sideEffects).toEqual([{ type: "flush_pending_updates" }]); + }); + + it("atomically replaces history when a resume tail leaves a middle gap", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + kind: "user_message", + id: "stale-history", + text: "stale history", + timestamp: new Date(1040), + timelineCursor: { epoch: "epoch-1", seq: 40 }, + }, + ], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 240, nextSeq: 241 }, + startCursor: { seq: 200 }, + endCursor: { seq: 240 }, + hasOlder: true, + entries: [ + makeTimelineEntry(200, "latest 200", "user_message"), + makeTimelineEntry(240, "latest 240", "user_message"), + ], + }, + }); + + expect(result.commit).toBe("apply"); + expect(getUserTexts(result.tail)).toEqual(["latest 200", "latest 240"]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 200, endSeq: 240 }); + expect(result.sideEffects).toEqual([{ type: "flush_pending_updates" }]); + }); + + it("reconciles in-flight live rows and an unresolved submission during gap replacement", () => { + const unresolved = makeSubmittedUserMessage("unresolved prompt", "client-unresolved"); + const coveredLive: StreamItem = { + ...makeAssistantItem("covered live", "covered-live"), + timelineCursor: { epoch: "epoch-1", seq: 235 }, + }; + const newerLive: StreamItem = { + ...makeAssistantItem("newer live", "newer-live"), + timelineCursor: { epoch: "epoch-1", seq: 245 }, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("stale history", "stale-history"), unresolved], + currentHead: [coveredLive, newerLive], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 240, nextSeq: 241 }, + startCursor: { seq: 200 }, + endCursor: { seq: 240 }, + hasOlder: true, + entries: [makeTimelineEntry(240, "canonical tail")], + }, + }); + + expect(getAssistantTexts(result.tail)).toEqual(["canonical tail"]); + expect(getUserTexts(result.tail)).toEqual(["unresolved prompt"]); + expect(result.head).toEqual([newerLive]); + }); + + it("replaces destructively when a resume tail has a new epoch", () => { + const acknowledged: StreamItem = { + ...makeSubmittedUserMessage("acknowledged old prompt", "client-acknowledged"), + messageId: "provider-acknowledged", + }; + const sending = makeSubmittedUserMessage("still sending", "client-sending"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [acknowledged, sending], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + sendingClientMessageIds: ["client-sending"], + payload: { + ...baseTimelineInput.payload, + direction: "tail", + epoch: "epoch-2", + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [makeTimelineEntry(1, "new epoch")], + }, + }); + + expect(getAssistantTexts(result.tail)).toEqual(["new epoch"]); + expect(getUserTexts(result.tail)).toEqual(["still sending"]); + expect(result.cursor).toEqual({ epoch: "epoch-2", startSeq: 1, endSeq: 1 }); + }); + + it("replaces destructively when the same epoch rewinds behind the local cursor", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("future history", "future-history")], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 10, nextSeq: 11 }, + startCursor: { seq: 1 }, + endCursor: { seq: 10 }, + entries: [makeTimelineEntry(10, "rewound history")], + }, + }); + + expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 10 }); + }); + it("preserves the canonical end cursor on a projected assistant message", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -2266,6 +2439,7 @@ describe("processTimelineResponse", () => { // No new items appended (all dropped as stale) expect(result.tail).toBe(baseTimelineInput.currentTail); expect(result.cursorChanged).toBe(false); + expect(result.older).toBe("unchanged"); }); it("prepends older before-cursor entries and only expands the start cursor", () => { @@ -2310,6 +2484,43 @@ describe("processTimelineResponse", () => { startSeq: 1, endSeq: 5, }); + expect(result.older).toBe("none"); + }); + + it("drops a stale before page anchored before a resume-tail replacement", () => { + const currentTail: StreamItem[] = [ + { + kind: "user_message", + id: "current-200", + text: "current-200", + timestamp: new Date(3200), + timelineCursor: { epoch: "epoch-1", seq: 200 }, + }, + ]; + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 200, + endSeq: 240, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: existingCursor, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 39 }, + entries: [makeTimelineEntry(39, "stale older page", "user_message")], + }, + }); + + expect(result.tail).toBe(currentTail); + expect(result.cursor).toBe(existingCursor); + expect(result.cursorChanged).toBe(false); + expect(result.older).toBe("unchanged"); }); it("does not reconcile an active submitted user message from a before-page response", () => { @@ -3344,6 +3555,7 @@ describe("processAgentStreamEvents", () => { direction: "tail", reset: false, epoch: "epoch-1", + window: { minSeq: 186, maxSeq: 186, nextSeq: 187 }, startCursor: { seq: 186 }, endCursor: { seq: 186 }, entries: [makeTimelineEntry(186, seq186Text)], @@ -3387,6 +3599,7 @@ describe("processAgentStreamEvents", () => { direction: "tail", reset: false, epoch: "epoch-1", + window: { minSeq: 10, maxSeq: 10, nextSeq: 11 }, startCursor: { seq: 10 }, endCursor: { seq: 10 }, entries: [makeTimelineEntry(10, "Call-site API — exactly one primitive. Not")], diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 7c151adf05..2179ad637b 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -75,6 +75,7 @@ export interface ProcessTimelineResponseInput { direction: TimelineDirection; reset: boolean; epoch: string; + window: { minSeq: number; maxSeq: number; nextSeq: number }; startCursor: { seq: number } | null; endCursor: { seq: number } | null; entries: TimelineResponseEntry[]; @@ -92,10 +93,12 @@ export interface ProcessTimelineResponseInput { } export interface ProcessTimelineResponseOutput { + commit: "apply" | "discard"; tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor | null | undefined; cursorChanged: boolean; + older: "available" | "none" | "unchanged"; initResolution: "resolve" | "reject" | null; clearInitializing: boolean; error: string | null; @@ -116,6 +119,7 @@ interface TimelinePathResult { head: StreamItem[]; cursor: TimelineCursor | null | undefined; cursorChanged: boolean; + older: "available" | "none" | "unchanged"; sideEffects: TimelineReducerSideEffect[]; acknowledgedClientMessageIds: string[]; } @@ -177,6 +181,39 @@ function deriveBootstrapTailTimelinePolicy({ }; } +type ResumeTailPolicy = + | { kind: "not_resume" } + | { kind: "discard" } + | { kind: "append" } + | { kind: "replace"; preserveContinuity: boolean }; + +function deriveResumeTailPolicy(input: { + direction: TimelineDirection; + reset: boolean; + epoch: string; + windowMaxSeq: number; + pageStartSeq: number | null; + currentCursor: TimelineCursor | undefined; + bootstrapReplace: boolean; +}): ResumeTailPolicy { + if (input.direction !== "tail" || input.reset || input.bootstrapReplace || !input.currentCursor) { + return { kind: "not_resume" }; + } + if (input.currentCursor.epoch !== input.epoch) { + return { kind: "replace", preserveContinuity: false }; + } + if (input.windowMaxSeq === input.currentCursor.endSeq) { + return { kind: "discard" }; + } + if (input.windowMaxSeq < input.currentCursor.endSeq) { + return { kind: "replace", preserveContinuity: false }; + } + if (input.pageStartSeq !== null && input.pageStartSeq <= input.currentCursor.endSeq + 1) { + return { kind: "append" }; + } + return { kind: "replace", preserveContinuity: true }; +} + function shouldResolveTimelineInit({ hasActiveInitDeferred, hasNewer, @@ -255,6 +292,7 @@ function applyTimelineReplacePath(args: { head, cursor, cursorChanged: true, + older: payload.hasOlder ? "available" : "none", sideEffects, acknowledgedClientMessageIds, }; @@ -340,7 +378,7 @@ function acceptOlderTimelineUnits(args: { if ( responseStartSeq === undefined || responseEndSeq === undefined || - responseEndSeq >= currentCursor.startSeq + responseEndSeq !== currentCursor.startSeq - 1 ) { return { acceptedUnits: [], cursor: currentCursor, gapCursor: null }; } @@ -733,6 +771,7 @@ function applyTimelineIncrementalPath(args: { let nextHead = currentHead; let nextCursor: TimelineCursor | null | undefined = currentCursor; let cursorChanged = false; + let older: TimelinePathResult["older"] = "unchanged"; const sideEffects: TimelineReducerSideEffect[] = []; let acknowledgedClientMessageIds: string[] = []; @@ -742,6 +781,7 @@ function applyTimelineIncrementalPath(args: { head: nextHead, cursor: nextCursor, cursorChanged, + older, sideEffects, acknowledgedClientMessageIds, }; @@ -762,6 +802,7 @@ function applyTimelineIncrementalPath(args: { if (acceptedUnits.length > 0) { if (payload.direction === "before") { + older = payload.hasOlder ? "available" : "none"; const olderTail = hydrateStreamState( acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ event, @@ -805,6 +846,7 @@ function applyTimelineIncrementalPath(args: { head: nextHead, cursor: nextCursor, cursorChanged, + older, sideEffects, acknowledgedClientMessageIds, }; @@ -829,10 +871,12 @@ export function processTimelineResponse( // ------------------------------------------------------------------ if (payload.error) { return { + commit: "apply", tail: currentTail, head: currentHead, cursor: currentCursor, cursorChanged: false, + older: "unchanged", initResolution: hasActiveInitDeferred ? "reject" : null, clearInitializing: isInitializing, error: payload.error, @@ -885,24 +929,56 @@ export function processTimelineResponse( }); const replace = bootstrapPolicy.replace; const sideEffects: TimelineReducerSideEffect[] = []; - const timelineResult = replace - ? applyTimelineReplacePath({ - timelineUnits, - payload, - bootstrapPolicy, - currentTail, - currentHead, - sendingClientMessageIds, - preserveContinuity: currentCursor?.epoch === payload.epoch || !payload.reset, - toHydratedEvents, - }) - : applyTimelineIncrementalPath({ - timelineUnits, - payload, - currentTail, - currentHead, - currentCursor, - }); + const resumeTailPolicy = deriveResumeTailPolicy({ + direction: payload.direction, + reset: payload.reset, + epoch: payload.epoch, + windowMaxSeq: payload.window.maxSeq, + pageStartSeq: payload.startCursor?.seq ?? null, + currentCursor, + bootstrapReplace: replace, + }); + const discard = resumeTailPolicy.kind === "discard"; + let timelineResult: TimelinePathResult; + if (discard) { + timelineResult = { + tail: currentTail, + head: currentHead, + cursor: currentCursor, + cursorChanged: false, + older: "unchanged", + sideEffects: [], + acknowledgedClientMessageIds: [], + }; + } else if (replace || resumeTailPolicy.kind === "replace") { + const isResumeReplacement = resumeTailPolicy.kind === "replace"; + timelineResult = applyTimelineReplacePath({ + timelineUnits, + payload, + bootstrapPolicy: isResumeReplacement + ? { replace: true, catchUpCursor: null } + : bootstrapPolicy, + currentTail, + currentHead, + sendingClientMessageIds, + preserveContinuity: isResumeReplacement + ? resumeTailPolicy.preserveContinuity + : currentCursor?.epoch === payload.epoch || !payload.reset, + toHydratedEvents, + }); + } else { + const incrementalUnits = + resumeTailPolicy.kind === "append" && currentCursor + ? timelineUnits.filter((unit) => unit.seqEnd > currentCursor.endSeq) + : timelineUnits; + timelineResult = applyTimelineIncrementalPath({ + timelineUnits: incrementalUnits, + payload, + currentTail, + currentHead, + currentCursor, + }); + } const nextTail = timelineResult.tail; const nextHead = timelineResult.head; @@ -934,10 +1010,12 @@ export function processTimelineResponse( const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; return { + commit: discard ? "discard" : "apply", tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, + older: timelineResult.older, initResolution, clearInitializing, error: null, diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 918c973576..aa038c4240 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -2,54 +2,13 @@ import { describe, expect, test } from "vitest"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import { isTimelineCatchUpComplete, - planInitialAgentTimelineSync, - planResumeTimelineSync, planTimelineOlderFetch, + planTimelineTailFetch, } from "./timeline-sync-plan"; describe("timeline sync planning", () => { - test("initial open without an authoritative cursor loads a bounded tail page", () => { - const plan = planInitialAgentTimelineSync({ - cursor: undefined, - hasAuthoritativeHistory: false, - }); - - expect(plan).toEqual({ - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - - test("initial open with an authoritative cursor catches up after the cursor", () => { - const plan = planInitialAgentTimelineSync({ - cursor: { epoch: "epoch-1", startSeq: 1, endSeq: 42 }, - hasAuthoritativeHistory: true, - }); - - expect(plan).toEqual({ - direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - - test("resume with a cursor catches up after the cursor", () => { - const plan = planResumeTimelineSync({ - cursor: { epoch: "epoch-1", startSeq: 1, endSeq: 100 }, - }); - - expect(plan).toEqual({ - direction: "after", - cursor: { epoch: "epoch-1", seq: 100 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - - test("resume without a cursor loads a bounded tail page", () => { - const plan = planResumeTimelineSync({ cursor: undefined }); + test("agent synchronization checks a bounded tail page", () => { + const plan = planTimelineTailFetch(); expect(plan).toEqual({ direction: "tail", diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index d1cd4b3764..69b590b985 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -5,12 +5,6 @@ export interface TimelineSyncCursor { seq: number; } -export interface AgentTimelineCursorRange { - epoch: string; - startSeq: number; - endSeq: number; -} - export interface ProjectedTimelineTailFetchPlan { direction: "tail"; limit: number; @@ -40,27 +34,6 @@ export type ProjectedTimelineForwardFetchPlan = | ProjectedTimelineTailFetchPlan | ProjectedTimelineAfterFetchPlan; -export function planInitialAgentTimelineSync(input: { - cursor: AgentTimelineCursorRange | undefined; - hasAuthoritativeHistory: boolean; -}): ProjectedTimelineForwardFetchPlan { - if (input.hasAuthoritativeHistory && input.cursor) { - return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq }); - } - - return planTimelineTailFetch(); -} - -export function planResumeTimelineSync(input: { - cursor: AgentTimelineCursorRange | undefined; -}): ProjectedTimelineForwardFetchPlan { - if (input.cursor) { - return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq }); - } - - return planTimelineTailFetch(); -} - export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) { return { direction: "after", diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 307680aa37..edf5a7895f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -48,8 +48,6 @@ class TimelineWorld { this.releaseMembershipWaiter(); return result.promise; }, - readCursor: (agentId) => this.cursors.get(agentId), - hasAuthoritativeHistory: (agentId) => this.authoritativeHistory.has(agentId), fetchPage: async (agentId, request) => { const result = deferred<{ hasNewer: boolean; @@ -95,21 +93,10 @@ class TimelineWorld { agentId: string; resolve(fetch: TimelineFetch): void; }> = []; - private readonly cursors = new Map(); - private readonly authoritativeHistory = new Set(); private readonly errorWaiters: Array<(message: string) => void> = []; private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; private readonly retryWaiters: Array<(retry: () => void) => void> = []; - setCursor(agentId: string, endSeq: number): void { - this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); - this.authoritativeHistory.add(agentId); - } - - setLiveCursor(agentId: string, endSeq: number): void { - this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); - } - nextMembership(): Promise { const request = this.memberships.shift(); if (request) return Promise.resolve(request); @@ -178,9 +165,8 @@ test("keeps hidden agents subscribed for thirty seconds", () => { expect(VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS).toBe(30_000); }); -test("uses a tail fetch when a live cursor is not authoritative", async () => { +test("uses a tail fetch when an agent becomes visible", async () => { const world = new TimelineWorld(); - world.setLiveCursor("agent-a", 9); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); @@ -191,6 +177,30 @@ test("uses a tail fetch when a live cursor is not authoritative", async () => { fetch.respond({ hasNewer: false }); }); +test("a gap absorbed by a running tail is recovered after the tail completes", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const tail = await world.nextFetch("agent-a"); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + + world.expectNoPendingFetch(); + tail.respond({ hasNewer: false }); + + const recovery = await world.nextFetch("agent-a"); + expect(recovery.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 9 }, + limit: 40, + projection: "projected", + }); + recovery.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + test("unchanged visible-set publication does not cancel paged catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -364,6 +374,7 @@ test("gap recovery supersedes completed catch-up and pages through the current t membership.succeed(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); const gapPage = await world.nextFetch("agent-a"); @@ -395,6 +406,7 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as membership.succeed(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); const cursor = { epoch: "epoch-agent-a", endSeq: 10 }; world.sync.recoverGap("agent-a", cursor); @@ -656,6 +668,7 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u world.expectNoPendingMembership(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); const recovery = await world.nextFetch("agent-a"); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index a7151b4e73..1784083f3f 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -1,8 +1,6 @@ -import type { AgentTimelineCursorState } from "@/stores/session-store"; import { - planInitialAgentTimelineSync, - planResumeTimelineSync, planTimelineCatchUpAfter, + planTimelineTailFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; @@ -14,8 +12,6 @@ interface TimelinePageResult { interface ViewedTimelineSyncPorts { initialDeliveryMode: TimelineDeliveryMode; setSubscription(agentIds: string[]): Promise; - readCursor(agentId: string): AgentTimelineCursorState | undefined; - hasAuthoritativeHistory(agentId: string): boolean; fetchPage( agentId: string, request: ProjectedTimelineForwardFetchPlan, @@ -62,19 +58,29 @@ function isSameCatchUpRequest( return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq; } -function shouldKeepCurrentCatchUp(input: { +type CatchUpDecision = "keep" | "keep-and-park" | "replace"; + +function decideCatchUp(input: { current: CatchUpState | undefined; - request: ProjectedTimelineForwardFetchPlan | undefined; + request: ProjectedTimelineForwardFetchPlan; supersede: boolean; -}): boolean { - if (!input.current) return false; +}): CatchUpDecision { + if (!input.current) return "replace"; if (input.supersede) { - return ( + if ( input.current.status === "running" && isSameCatchUpRequest(input.current.request, input.request) - ); + ) { + return "keep"; + } + if (input.current.status === "running" && input.current.request?.direction === "tail") { + return "keep-and-park"; + } + return "replace"; } - return input.current.status === "running" || input.current.status === "complete"; + return input.current.status === "running" || input.current.status === "complete" + ? "keep" + : "replace"; } function normalizeAgentIds(agentIds: string[]): string[] { @@ -89,6 +95,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const sources = new Map(); const catchUps = new Map(); const catchUpGenerations = new Map(); + // Recovery owed but not runnable yet: disconnected, unacknowledged, or parked behind a tail. + // Acknowledgement and tail completion are the only drain points. const pendingGaps = new Map(); const lingeringRemovals = new Map void>(); const visibilityCatchUpPending = new Set(); @@ -174,6 +182,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); } catchUps.set(agentId, { generation, status: "complete" }); + const pendingGap = pendingGaps.get(agentId); + if (pendingGap) { + startCatchUp(agentId, { request: pendingGap, supersede: true }); + return; + } setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { @@ -201,21 +214,21 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (request) pendingGaps.set(agentId, request); return; } + const nextRequest = request ?? planTimelineTailFetch(); const current = catchUps.get(agentId); - if (shouldKeepCurrentCatchUp({ current, request, supersede })) { + const decision = decideCatchUp({ current, request: nextRequest, supersede }); + if (decision === "keep-and-park") { + pendingGaps.set(agentId, nextRequest); + return; + } + if (decision === "keep") { return; } current?.cancelRetry?.(); const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; catchUpGenerations.set(agentId, generation); - catchUps.set(agentId, { generation, status: "running", request }); + catchUps.set(agentId, { generation, status: "running", request: nextRequest }); pendingGaps.delete(agentId); - const cursor = ports.readCursor(agentId); - const nextRequest = - request ?? - (ports.hasAuthoritativeHistory(agentId) - ? planResumeTimelineSync({ cursor }) - : planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory: false })); void fetchUntilCurrent(agentId, generation, nextRequest); };