Fix mobile agent timeline catch-up

This commit is contained in:
Mohamed Boudra
2026-05-29 09:31:16 +07:00
parent 2e1b907c63
commit 65ace20880
11 changed files with 410 additions and 77 deletions
@@ -919,6 +919,24 @@ describe("processTimelineResponse", () => {
expect(result.clearInitializing).toBe(true);
});
it("keeps init open while an after catch-up page has newer rows", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
payload: {
...baseTimelineInput.payload,
direction: "after",
hasNewer: true,
entries: [],
},
});
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(false);
});
it("does not resolve init when directions differ (before vs after)", () => {
const result = processTimelineResponse({
...baseTimelineInput,
@@ -167,12 +167,14 @@ function deriveBootstrapTailTimelinePolicy({
function shouldResolveTimelineInit({
hasActiveInitDeferred,
hasNewer,
isInitializing,
initRequestDirection,
responseDirection,
reset,
}: {
hasActiveInitDeferred: boolean;
hasNewer: boolean;
isInitializing: boolean;
initRequestDirection: InitRequestDirection;
responseDirection: TimelineDirection;
@@ -184,6 +186,9 @@ function shouldResolveTimelineInit({
if (reset) {
return true;
}
if (responseDirection === "after" && hasNewer) {
return false;
}
return responseDirection === initRequestDirection;
}
@@ -652,12 +657,16 @@ export function processTimelineResponse(
// ------------------------------------------------------------------
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
hasNewer: payload.hasNewer,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
reset: payload.reset,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
const timelineResponseComplete = payload.direction !== "after" || !payload.hasNewer;
const clearInitializing =
(shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred)) &&
timelineResponseComplete;
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
@@ -0,0 +1,102 @@
import { describe, expect, test } from "vitest";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import {
isTimelineCatchUpComplete,
planInitialAgentTimelineSync,
planResumeTimelineSync,
planTimelineCatchUpFollowUp,
planTimelineOlderFetch,
} 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: "canonical",
});
});
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: "canonical",
});
});
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: "canonical",
});
});
test("resume without a cursor loads a bounded tail page", () => {
const plan = planResumeTimelineSync({ cursor: undefined });
expect(plan).toEqual({
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
});
test("older history loads one bounded page before the start cursor", () => {
const plan = planTimelineOlderFetch({ epoch: "epoch-1", seq: 25 });
expect(plan).toEqual({
direction: "before",
cursor: { epoch: "epoch-1", seq: 25 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
});
test("catch-up keeps paging while the daemon reports newer rows", () => {
const plan = planTimelineCatchUpFollowUp({
direction: "after",
hasNewer: true,
endCursor: { epoch: "epoch-1", seq: 200 },
error: null,
});
expect(plan).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 200 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
});
test("catch-up finishes when the daemon reports no newer rows", () => {
const plan = planTimelineCatchUpFollowUp({
direction: "after",
hasNewer: false,
endCursor: { epoch: "epoch-1", seq: 200 },
error: null,
});
expect(plan).toBeNull();
expect(isTimelineCatchUpComplete({ direction: "after", hasNewer: false, error: null })).toBe(
true,
);
});
});
@@ -0,0 +1,113 @@
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
export interface TimelineSyncCursor {
epoch: string;
seq: number;
}
export interface AgentTimelineCursorRange {
epoch: string;
startSeq: number;
endSeq: number;
}
export interface CanonicalTimelineTailFetchPlan {
direction: "tail";
limit: number;
projection: "canonical";
}
export interface CanonicalTimelineAfterFetchPlan {
direction: "after";
cursor: TimelineSyncCursor;
limit: number;
projection: "canonical";
}
export interface CanonicalTimelineBeforeFetchPlan {
direction: "before";
cursor: TimelineSyncCursor;
limit: number;
projection: "canonical";
}
export type CanonicalTimelineFetchPlan =
| CanonicalTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan
| CanonicalTimelineBeforeFetchPlan;
export type CanonicalTimelineForwardFetchPlan =
| CanonicalTimelineTailFetchPlan
| CanonicalTimelineAfterFetchPlan;
export function planInitialAgentTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined;
hasAuthoritativeHistory: boolean;
}): CanonicalTimelineForwardFetchPlan {
if (input.hasAuthoritativeHistory && input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
}
return planTimelineTailFetch();
}
export function planResumeTimelineSync(input: {
cursor: AgentTimelineCursorRange | undefined;
}): CanonicalTimelineForwardFetchPlan {
if (input.cursor) {
return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq });
}
return planTimelineTailFetch();
}
export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
return {
direction: "after",
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
} as const;
}
export function planTimelineTailFetch() {
return {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
} as const;
}
export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
return {
direction: "before",
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
} as const;
}
export function planTimelineCatchUpFollowUp(input: {
direction: "tail" | "before" | "after";
hasNewer: boolean;
endCursor: TimelineSyncCursor | null;
error: string | null;
}): CanonicalTimelineAfterFetchPlan | null {
if (input.error || input.direction !== "after" || !input.hasNewer || !input.endCursor) {
return null;
}
return planTimelineCatchUpAfter(input.endCursor);
}
export function isTimelineCatchUpComplete(input: {
direction: "tail" | "before" | "after";
hasNewer: boolean;
error: string | null;
}): boolean {
if (input.error) {
return false;
}
return input.direction !== "after" || !input.hasNewer;
}