Reduce workspace, agent, and chat sync traffic (#2028)
* feat(sync): keep live data scoped and current Only viewed chats receive live timeline rows, while directory state now uses one subscribed bootstrap followed by ordered deltas. Legacy clients and daemons retain their existing behavior through centralized compatibility gates. * fix(sync): preserve selective delivery boundaries Keep selective timeline capability app-owned, union viewed sets across shared sockets, and reconcile directory side effects from accepted state. Visibility and archive suppression now follow their existing authoritative boundaries. * test(app): align browser fixtures with runtime contracts * fix(sync): preserve mixed-client delivery guarantees * fix(sync): finish paged history before advancing * fix(sync): replay live deltas after refresh failures * test(server): model socket capability lookup * test(app): use platform shortcut for split-pane coverage * fix(app): keep hidden timelines dormant * test(app): normalize split-pane shortcut setup * fix(app): preserve sync state across retries * fix(app): preserve background sync state across races * fix directory bootstrap reconciliation edge cases * fix selective timeline compatibility races * fix superseded directory sync races * fix(sync): centralize directory replica ordering Own agent and workspace refresh transactions in HostRuntime so reconnect epochs, buffered updates, and lifecycle invalidation share one ordering boundary. Route timeline metadata and mixed-capability stream delivery through their authoritative runtime sources. * fix(app): keep timeline requests runtime-scoped * fix(app): close directory bootstrap races
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { fetchAgentTimelineOnce } from "./fetch-agent-timeline-once";
|
||||
|
||||
type TimelinePage = Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>;
|
||||
|
||||
test("concurrent identical timeline reads share one request", async () => {
|
||||
let resolvePage: (page: TimelinePage) => void = () => {};
|
||||
const page = new Promise<TimelinePage>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
});
|
||||
const requests: Array<{ agentId: string; direction: string }> = [];
|
||||
const client = {
|
||||
fetchAgentTimeline: async (
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
) => {
|
||||
requests.push({ agentId, direction: request?.direction ?? "tail" });
|
||||
return page;
|
||||
},
|
||||
};
|
||||
const request = { direction: "tail" as const, limit: 100, projection: "projected" as const };
|
||||
|
||||
const first = fetchAgentTimelineOnce(client, "agent", request);
|
||||
const second = fetchAgentTimelineOnce(client, "agent", request);
|
||||
resolvePage({ hasNewer: false } as TimelinePage);
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toHaveLength(2);
|
||||
expect(requests).toEqual([{ agentId: "agent", direction: "tail" }]);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
type TimelineClient = Pick<DaemonClient, "fetchAgentTimeline">;
|
||||
type TimelineRequest = Parameters<TimelineClient["fetchAgentTimeline"]>[1];
|
||||
type TimelinePage = Awaited<ReturnType<TimelineClient["fetchAgentTimeline"]>>;
|
||||
|
||||
const inFlightByClient = new WeakMap<object, Map<string, Promise<TimelinePage>>>();
|
||||
|
||||
export function fetchAgentTimelineOnce(
|
||||
client: TimelineClient,
|
||||
agentId: string,
|
||||
request: TimelineRequest,
|
||||
): Promise<TimelinePage> {
|
||||
let inFlight = inFlightByClient.get(client);
|
||||
if (!inFlight) {
|
||||
inFlight = new Map();
|
||||
inFlightByClient.set(client, inFlight);
|
||||
}
|
||||
|
||||
const key = `${agentId}:${JSON.stringify(request)}`;
|
||||
const existing = inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const fetch = client.fetchAgentTimeline(agentId, request);
|
||||
inFlight.set(key, fetch);
|
||||
const clear = () => {
|
||||
if (inFlight.get(key) === fetch) inFlight.delete(key);
|
||||
};
|
||||
void fetch.then(clear, clear);
|
||||
return fetch;
|
||||
}
|
||||
@@ -1160,7 +1160,7 @@ export interface CreateSessionAgentStreamReducerQueueInput {
|
||||
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
|
||||
) => void;
|
||||
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
|
||||
requestCanonicalCatchUp: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
|
||||
}
|
||||
|
||||
function scheduleAgentStreamReducerFlush(callback: () => void): number {
|
||||
@@ -1174,13 +1174,8 @@ function cancelAgentStreamReducerFlush(id: number) {
|
||||
export function createSessionAgentStreamReducerQueue(
|
||||
input: CreateSessionAgentStreamReducerQueueInput,
|
||||
): AgentStreamReducerQueue {
|
||||
const {
|
||||
serverId,
|
||||
setAgentStreamState,
|
||||
setAgentTimelineCursor,
|
||||
setAgents,
|
||||
requestCanonicalCatchUp,
|
||||
} = input;
|
||||
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } =
|
||||
input;
|
||||
|
||||
return createAgentStreamReducerQueue({
|
||||
getSnapshot: (agentId) => {
|
||||
@@ -1258,7 +1253,7 @@ export function createSessionAgentStreamReducerQueue(
|
||||
handleSideEffects: (agentId, sideEffects) => {
|
||||
for (const effect of sideEffects) {
|
||||
if (effect.type === "catch_up") {
|
||||
requestCanonicalCatchUp(agentId, effect.cursor);
|
||||
recoverTimelineGap(agentId, effect.cursor);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
isTimelineCatchUpComplete,
|
||||
planInitialAgentTimelineSync,
|
||||
planResumeTimelineSync,
|
||||
planTimelineCatchUpFollowUp,
|
||||
planTimelineOlderFetch,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
@@ -70,31 +69,7 @@ describe("timeline sync planning", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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: "projected",
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -87,19 +87,6 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function planTimelineCatchUpFollowUp(input: {
|
||||
direction: "tail" | "before" | "after";
|
||||
hasNewer: boolean;
|
||||
endCursor: TimelineSyncCursor | null;
|
||||
error: string | null;
|
||||
}): ProjectedTimelineAfterFetchPlan | 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;
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
reject(error: Error): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
interface MembershipRequest {
|
||||
agentIds: string[];
|
||||
succeed(): void;
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
interface TimelineFetch {
|
||||
agentId: string;
|
||||
request: ProjectedTimelineForwardFetchPlan;
|
||||
respond(input: { hasNewer: boolean; seq?: number }): void;
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
class TimelineWorld {
|
||||
readonly errors: string[] = [];
|
||||
readonly sync = createViewedTimelineSync({
|
||||
setSubscription: async (agentIds) => {
|
||||
const result = deferred<void>();
|
||||
this.memberships.push({
|
||||
agentIds,
|
||||
succeed: () => result.resolve(),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
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;
|
||||
endCursor: { epoch: string; seq: number } | null;
|
||||
}>();
|
||||
this.fetches.push({
|
||||
agentId,
|
||||
request,
|
||||
respond: ({ hasNewer, seq = 1 }) =>
|
||||
result.resolve({
|
||||
hasNewer,
|
||||
endCursor: { epoch: `epoch-${agentId}`, seq },
|
||||
}),
|
||||
fail: (message) => result.reject(new Error(message)),
|
||||
});
|
||||
this.releaseFetchWaiters();
|
||||
return result.promise;
|
||||
},
|
||||
reportError: (error) => {
|
||||
this.errors.push(error instanceof Error ? error.message : String(error));
|
||||
const waiter = this.errorWaiters.shift();
|
||||
if (waiter) waiter(this.errors.at(-1) ?? "");
|
||||
},
|
||||
scheduleRetry: (retry) => {
|
||||
this.retries.push(retry);
|
||||
const waiter = this.retryWaiters.shift();
|
||||
if (waiter) waiter(this.retries.shift()!);
|
||||
return () => {
|
||||
const index = this.retries.indexOf(retry);
|
||||
if (index >= 0) this.retries.splice(index, 1);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
private readonly memberships: MembershipRequest[] = [];
|
||||
private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = [];
|
||||
private readonly fetches: TimelineFetch[] = [];
|
||||
private readonly fetchWaiters: Array<{
|
||||
agentId: string;
|
||||
resolve(fetch: TimelineFetch): void;
|
||||
}> = [];
|
||||
private readonly cursors = new Map<string, { epoch: string; startSeq: number; endSeq: number }>();
|
||||
private readonly authoritativeHistory = new Set<string>();
|
||||
private readonly errorWaiters: Array<(message: string) => void> = [];
|
||||
private readonly retries: Array<() => void> = [];
|
||||
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<MembershipRequest> {
|
||||
const request = this.memberships.shift();
|
||||
if (request) return Promise.resolve(request);
|
||||
return new Promise((resolve) => this.membershipWaiters.push(resolve));
|
||||
}
|
||||
|
||||
nextFetch(agentId: string): Promise<TimelineFetch> {
|
||||
const index = this.fetches.findIndex((fetch) => fetch.agentId === agentId);
|
||||
if (index >= 0) return Promise.resolve(this.fetches.splice(index, 1)[0]);
|
||||
return new Promise((resolve) => this.fetchWaiters.push({ agentId, resolve }));
|
||||
}
|
||||
|
||||
expectNoPendingMembership(): void {
|
||||
expect(this.memberships).toEqual([]);
|
||||
}
|
||||
|
||||
expectNoPendingFetch(): void {
|
||||
expect(this.fetches).toEqual([]);
|
||||
}
|
||||
|
||||
nextError(): Promise<string> {
|
||||
const message = this.errors.at(-1);
|
||||
if (message) return Promise.resolve(message);
|
||||
return new Promise((resolve) => this.errorWaiters.push(resolve));
|
||||
}
|
||||
|
||||
nextRetry(): Promise<() => void> {
|
||||
const retry = this.retries.shift();
|
||||
if (retry) return Promise.resolve(retry);
|
||||
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
||||
}
|
||||
|
||||
private releaseMembershipWaiter(): void {
|
||||
const waiter = this.membershipWaiters.shift();
|
||||
if (!waiter) return;
|
||||
const request = this.memberships.shift();
|
||||
if (request) waiter(request);
|
||||
}
|
||||
|
||||
private releaseFetchWaiters(): void {
|
||||
for (let waiterIndex = this.fetchWaiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) {
|
||||
const waiter = this.fetchWaiters[waiterIndex];
|
||||
const index = this.fetches.findIndex((fetch) => fetch.agentId === waiter.agentId);
|
||||
if (index < 0) continue;
|
||||
this.fetchWaiters.splice(waiterIndex, 1);
|
||||
waiter.resolve(this.fetches.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("uses a tail fetch when a live cursor is not authoritative", 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();
|
||||
membership.succeed();
|
||||
|
||||
const fetch = await world.nextFetch("agent-a");
|
||||
expect(fetch.request).toEqual({ direction: "tail", limit: 100, projection: "projected" });
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const firstPage = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-a"]);
|
||||
firstPage.respond({ hasNewer: true, seq: 5 });
|
||||
const secondPage = await world.nextFetch("agent-a");
|
||||
secondPage.respond({ hasNewer: false });
|
||||
|
||||
expect(secondPage.request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 5 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
});
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("all acknowledged agents begin catch-up independently", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b", "agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
expect(membership.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
});
|
||||
|
||||
test("membership changes during acknowledgement never catch up the stale set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const staleMembership = await world.nextMembership();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
staleMembership.succeed();
|
||||
const currentMembership = await world.nextMembership();
|
||||
currentMembership.succeed();
|
||||
const agentB = await world.nextFetch("agent-b");
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({
|
||||
stale: ["agent-a"],
|
||||
current: ["agent-b"],
|
||||
});
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("removing one agent during paging cancels only that agent", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a", "agent-b"]);
|
||||
const initialMembership = await world.nextMembership();
|
||||
initialMembership.succeed();
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const replacement = await world.nextMembership();
|
||||
agentA.respond({ hasNewer: true, seq: 4 });
|
||||
agentB.respond({ hasNewer: true, seq: 7 });
|
||||
const agentBNext = await world.nextFetch("agent-b");
|
||||
replacement.succeed();
|
||||
agentBNext.respond({ hasNewer: false });
|
||||
|
||||
expect(replacement.agentIds).toEqual(["agent-b"]);
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const firstMembership = await world.nextMembership();
|
||||
firstMembership.succeed();
|
||||
const stalePage = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.setConnected(false);
|
||||
stalePage.respond({ hasNewer: true, seq: 8 });
|
||||
world.sync.setConnected(true);
|
||||
const restoredMembership = await world.nextMembership();
|
||||
restoredMembership.succeed();
|
||||
const restoredPage = await world.nextFetch("agent-a");
|
||||
restoredPage.respond({ hasNewer: false });
|
||||
|
||||
expect(restoredMembership.agentIds).toEqual(["agent-a"]);
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("overlapping sources deduplicate membership and source removal preserves remaining views", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]);
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]);
|
||||
world.sync.setConnected(true);
|
||||
const combined = await world.nextMembership();
|
||||
combined.succeed();
|
||||
const [agentA, agentB] = await Promise.all([
|
||||
world.nextFetch("agent-a"),
|
||||
world.nextFetch("agent-b"),
|
||||
]);
|
||||
agentA.respond({ hasNewer: false });
|
||||
agentB.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("left-route", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
||||
const remaining = await world.nextMembership();
|
||||
remaining.succeed();
|
||||
|
||||
expect({ combined: combined.agentIds, remaining: remaining.agentIds }).toEqual({
|
||||
combined: ["agent-a", "agent-b"],
|
||||
remaining: ["agent-b"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const failed = await world.nextFetch("agent-a");
|
||||
failed.fail("timeline unavailable");
|
||||
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
|
||||
retryCatchUp();
|
||||
const retry = await world.nextFetch("agent-a");
|
||||
retry.respond({ hasNewer: false });
|
||||
|
||||
expect({ error, retryDirection: retry.request.direction }).toEqual({
|
||||
error: "timeline unavailable",
|
||||
retryDirection: "tail",
|
||||
});
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("gap recovery supersedes completed catch-up and pages through the current tail", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
initial.respond({ hasNewer: false });
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
const gapPage = await world.nextFetch("agent-a");
|
||||
gapPage.respond({ hasNewer: true, seq: 15 });
|
||||
const finalPage = await world.nextFetch("agent-a");
|
||||
finalPage.respond({ hasNewer: false });
|
||||
|
||||
expect([gapPage.request, finalPage.request]).toEqual([
|
||||
{
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 10 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
},
|
||||
{
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 15 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("repeated recovery for the same running gap reuses the in-flight fetch", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
initial.respond({ hasNewer: false });
|
||||
|
||||
const cursor = { epoch: "epoch-agent-a", endSeq: 10 };
|
||||
world.sync.recoverGap("agent-a", cursor);
|
||||
const gapPage = await world.nextFetch("agent-a");
|
||||
world.sync.recoverGap("agent-a", cursor);
|
||||
|
||||
world.expectNoPendingFetch();
|
||||
gapPage.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("membership failure autonomously retries without another visibility declaration", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
|
||||
retryMembership();
|
||||
const retry = await world.nextMembership();
|
||||
retry.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect({ error, failed: failed.agentIds, retry: retry.agentIds }).toEqual({
|
||||
error: "subscription unavailable",
|
||||
failed: ["agent-a"],
|
||||
retry: ["agent-a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("background sends an empty set and foreground restores visible membership", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const initial = await world.nextMembership();
|
||||
initial.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.setActive(false);
|
||||
const background = await world.nextMembership();
|
||||
background.succeed();
|
||||
world.sync.setActive(true);
|
||||
const foreground = await world.nextMembership();
|
||||
foreground.succeed();
|
||||
const resumedCatchUp = await world.nextFetch("agent-a");
|
||||
resumedCatchUp.respond({ hasNewer: false });
|
||||
|
||||
expect({ background: background.agentIds, foreground: foreground.agentIds }).toEqual({
|
||||
background: [],
|
||||
foreground: ["agent-a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("stale membership retry cannot overwrite a newer effective set", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const staleRetry = await world.nextRetry();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const current = await world.nextMembership();
|
||||
staleRetry();
|
||||
current.succeed();
|
||||
const catchUp = await world.nextFetch("agent-b");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(current.agentIds).toEqual(["agent-b"]);
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("membership retry cannot run while disconnected", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const disconnectedRetry = await world.nextRetry();
|
||||
|
||||
world.sync.setConnected(false);
|
||||
disconnectedRetry();
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.setConnected(true);
|
||||
const restored = await world.nextMembership();
|
||||
restored.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(restored.agentIds).toEqual(["agent-a"]);
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import type { AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import {
|
||||
planInitialAgentTimelineSync,
|
||||
planResumeTimelineSync,
|
||||
planTimelineCatchUpAfter,
|
||||
type ProjectedTimelineForwardFetchPlan,
|
||||
} from "./timeline-sync-plan";
|
||||
|
||||
interface TimelinePageResult {
|
||||
hasNewer: boolean;
|
||||
endCursor: { epoch: string; seq: number } | null;
|
||||
}
|
||||
|
||||
interface ViewedTimelineSyncPorts {
|
||||
setSubscription(agentIds: string[]): Promise<void>;
|
||||
readCursor(agentId: string): AgentTimelineCursorState | undefined;
|
||||
hasAuthoritativeHistory(agentId: string): boolean;
|
||||
fetchPage(
|
||||
agentId: string,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<TimelinePageResult>;
|
||||
reportError(error: unknown): void;
|
||||
scheduleRetry(retry: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineUiBridge {
|
||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||
}
|
||||
|
||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
setActive(active: boolean): void;
|
||||
setConnected(connected: boolean): void;
|
||||
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
type CatchUpStatus = "running" | "complete" | "error";
|
||||
|
||||
interface CatchUpState {
|
||||
generation: number;
|
||||
status: CatchUpStatus;
|
||||
request?: ProjectedTimelineForwardFetchPlan;
|
||||
cancelRetry?: () => void;
|
||||
}
|
||||
|
||||
function isSameCatchUpRequest(
|
||||
left: ProjectedTimelineForwardFetchPlan | undefined,
|
||||
right: ProjectedTimelineForwardFetchPlan | undefined,
|
||||
): boolean {
|
||||
if (!left || !right || left.direction !== right.direction) return false;
|
||||
if (left.direction !== "after" || right.direction !== "after") return true;
|
||||
return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq;
|
||||
}
|
||||
|
||||
function shouldKeepCurrentCatchUp(input: {
|
||||
current: CatchUpState | undefined;
|
||||
request: ProjectedTimelineForwardFetchPlan | undefined;
|
||||
supersede: boolean;
|
||||
}): boolean {
|
||||
if (!input.current) return false;
|
||||
if (input.supersede) {
|
||||
return (
|
||||
input.current.status === "running" &&
|
||||
isSameCatchUpRequest(input.current.request, input.request)
|
||||
);
|
||||
}
|
||||
return input.current.status === "running" || input.current.status === "complete";
|
||||
}
|
||||
|
||||
function normalizeAgentIds(agentIds: string[]): string[] {
|
||||
return [...new Set(agentIds)].filter(Boolean).sort();
|
||||
}
|
||||
|
||||
function sameAgentIds(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((agentId, index) => agentId === right[index]);
|
||||
}
|
||||
|
||||
export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): ViewedTimelineSync {
|
||||
const sources = new Map<string, string[]>();
|
||||
const catchUps = new Map<string, CatchUpState>();
|
||||
const catchUpGenerations = new Map<string, number>();
|
||||
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
let active = true;
|
||||
let connected = false;
|
||||
let disposed = false;
|
||||
let desired: string[] = [];
|
||||
let acknowledged: string[] = [];
|
||||
let membershipGeneration = 0;
|
||||
let reconciling = false;
|
||||
let reconcileRequested = false;
|
||||
let membershipNeedsRetry = false;
|
||||
let cancelMembershipRetry: (() => void) | null = null;
|
||||
|
||||
const effectiveAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
|
||||
|
||||
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||
|
||||
const cancelCatchUp = (agentId: string) => {
|
||||
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
|
||||
catchUps.get(agentId)?.cancelRetry?.();
|
||||
catchUps.delete(agentId);
|
||||
pendingGaps.delete(agentId);
|
||||
};
|
||||
|
||||
const fetchUntilCurrent = async (
|
||||
agentId: string,
|
||||
generation: number,
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<void> => {
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
!isDesired(agentId) ||
|
||||
!isAcknowledged(agentId) ||
|
||||
catchUps.get(agentId)?.generation !== generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await ports.fetchPage(agentId, request);
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
!isDesired(agentId) ||
|
||||
!isAcknowledged(agentId) ||
|
||||
catchUps.get(agentId)?.generation !== generation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (page.hasNewer && page.endCursor) {
|
||||
await fetchUntilCurrent(agentId, generation, planTimelineCatchUpAfter(page.endCursor));
|
||||
return;
|
||||
}
|
||||
if (page.hasNewer) {
|
||||
throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`);
|
||||
}
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
const cancelRetry = ports.scheduleRetry(() => {
|
||||
const current = catchUps.get(agentId);
|
||||
if (current?.generation !== generation || current.status !== "error") return;
|
||||
startCatchUp(agentId);
|
||||
});
|
||||
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
||||
ports.reportError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startCatchUp = (
|
||||
agentId: string,
|
||||
options: {
|
||||
request?: ProjectedTimelineForwardFetchPlan;
|
||||
supersede?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const { request, supersede = false } = options;
|
||||
if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) {
|
||||
if (request) pendingGaps.set(agentId, request);
|
||||
return;
|
||||
}
|
||||
const current = catchUps.get(agentId);
|
||||
if (shouldKeepCurrentCatchUp({ current, request, supersede })) {
|
||||
return;
|
||||
}
|
||||
current?.cancelRetry?.();
|
||||
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
|
||||
catchUpGenerations.set(agentId, generation);
|
||||
catchUps.set(agentId, { generation, status: "running", request });
|
||||
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);
|
||||
};
|
||||
|
||||
const startAcknowledgedCatchUps = () => {
|
||||
for (const agentId of acknowledged) {
|
||||
const gap = pendingGaps.get(agentId);
|
||||
startCatchUp(agentId, { request: gap, supersede: Boolean(gap) });
|
||||
}
|
||||
};
|
||||
|
||||
const reconcileLatestMembership = async (): Promise<void> => {
|
||||
if (disposed || !connected) return;
|
||||
const generation = membershipGeneration;
|
||||
const requested = desired;
|
||||
if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return;
|
||||
membershipNeedsRetry = false;
|
||||
try {
|
||||
await ports.setSubscription(requested);
|
||||
} catch (error) {
|
||||
membershipNeedsRetry = true;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = ports.scheduleRetry(() => {
|
||||
cancelMembershipRetry = null;
|
||||
if (
|
||||
disposed ||
|
||||
!connected ||
|
||||
membershipGeneration !== generation ||
|
||||
!sameAgentIds(desired, requested)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void reconcileMembership();
|
||||
});
|
||||
ports.reportError(error);
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
if (disposed || !connected) return;
|
||||
acknowledged = requested;
|
||||
if (generation !== membershipGeneration) {
|
||||
await reconcileLatestMembership();
|
||||
return;
|
||||
}
|
||||
startAcknowledgedCatchUps();
|
||||
if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership();
|
||||
};
|
||||
|
||||
const reconcileMembership = async () => {
|
||||
if (reconciling) {
|
||||
reconcileRequested = true;
|
||||
return;
|
||||
}
|
||||
if (disposed || !connected) return;
|
||||
reconciling = true;
|
||||
try {
|
||||
await reconcileLatestMembership();
|
||||
} finally {
|
||||
reconciling = false;
|
||||
if (reconcileRequested && !disposed && connected) {
|
||||
reconcileRequested = false;
|
||||
void reconcileMembership();
|
||||
} else if (
|
||||
!disposed &&
|
||||
connected &&
|
||||
!membershipNeedsRetry &&
|
||||
!sameAgentIds(desired, acknowledged)
|
||||
) {
|
||||
void reconcileMembership();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const retryFailedCatchUps = () => {
|
||||
for (const agentId of acknowledged) {
|
||||
if (catchUps.get(agentId)?.status === "error") startCatchUp(agentId);
|
||||
}
|
||||
};
|
||||
|
||||
const publishEffectiveMembership = () => {
|
||||
const nextDesired = effectiveAgentIds();
|
||||
if (sameAgentIds(nextDesired, desired)) {
|
||||
if (membershipNeedsRetry) void reconcileMembership();
|
||||
retryFailedCatchUps();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const agentId of desired) {
|
||||
if (!nextDesired.includes(agentId)) cancelCatchUp(agentId);
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
desired = nextDesired;
|
||||
membershipGeneration += 1;
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
return {
|
||||
replaceVisibleAgentIds(sourceId, agentIds) {
|
||||
const normalized = normalizeAgentIds(agentIds);
|
||||
if (normalized.length === 0) sources.delete(sourceId);
|
||||
else sources.set(sourceId, normalized);
|
||||
publishEffectiveMembership();
|
||||
},
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
active = nextActive;
|
||||
publishEffectiveMembership();
|
||||
},
|
||||
setConnected(nextConnected) {
|
||||
if (connected === nextConnected) return;
|
||||
connected = nextConnected;
|
||||
if (!connected) {
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
acknowledged = [];
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
return;
|
||||
}
|
||||
membershipGeneration += 1;
|
||||
void reconcileMembership();
|
||||
},
|
||||
recoverGap(agentId, cursor) {
|
||||
if (!isDesired(agentId)) return;
|
||||
startCatchUp(agentId, {
|
||||
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
|
||||
supersede: true,
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
sources.clear();
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = [];
|
||||
acknowledged = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user