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:
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
|
||||
import {
|
||||
createSetAgentInitializing,
|
||||
@@ -12,15 +13,28 @@ import {
|
||||
const serverId = "server-1";
|
||||
const agentId = "agent-1";
|
||||
|
||||
interface FakeDaemonClient {
|
||||
fetchAgentTimeline: ReturnType<typeof vi.fn>;
|
||||
refreshAgent: ReturnType<typeof vi.fn>;
|
||||
class FakeDaemonClient {
|
||||
readonly refreshedAgentIds: string[] = [];
|
||||
|
||||
async refreshAgent(requestedAgentId: string): Promise<void> {
|
||||
this.refreshedAgentIds.push(requestedAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
function makeClient(): FakeDaemonClient {
|
||||
return {
|
||||
fetchAgentTimeline: vi.fn().mockResolvedValue(undefined),
|
||||
refreshAgent: vi.fn().mockResolvedValue(undefined),
|
||||
class FakeTimelineRuntime {
|
||||
readonly requests: Array<{
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
request: Parameters<HostRuntimeStore["fetchAgentTimeline"]>[2];
|
||||
}> = [];
|
||||
|
||||
fetchAgentTimeline: HostRuntimeStore["fetchAgentTimeline"] = async (
|
||||
requestedServerId,
|
||||
requestedAgentId,
|
||||
request,
|
||||
) => {
|
||||
this.requests.push({ serverId: requestedServerId, agentId: requestedAgentId, request });
|
||||
return undefined as never;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +50,8 @@ afterEach(() => {
|
||||
|
||||
describe("ensureAgentIsInitialized", () => {
|
||||
it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => {
|
||||
const client = makeClient();
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
useSessionStore
|
||||
.getState()
|
||||
@@ -50,46 +65,63 @@ describe("ensureAgentIsInitialized", () => {
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
});
|
||||
expect(runtime.requests).toEqual([
|
||||
{
|
||||
serverId,
|
||||
agentId,
|
||||
request: {
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
|
||||
});
|
||||
|
||||
it("requests a bounded projected tail when no authoritative cursor is available", () => {
|
||||
const client = makeClient();
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
|
||||
void ensureAgentIsInitialized({
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
});
|
||||
expect(runtime.requests).toEqual([
|
||||
{
|
||||
serverId,
|
||||
agentId,
|
||||
request: {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
|
||||
it("times out initialization after 65 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient();
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
|
||||
const promise = ensureAgentIsInitialized({
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
@@ -108,7 +140,8 @@ describe("ensureAgentIsInitialized", () => {
|
||||
|
||||
it("refreshes the initialization timeout after paged catch-up progress", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = makeClient();
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
const setAgentInitializing = bindSetAgentInitializing();
|
||||
const key = getInitKey(serverId, agentId);
|
||||
@@ -117,6 +150,7 @@ describe("ensureAgentIsInitialized", () => {
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing,
|
||||
});
|
||||
|
||||
@@ -141,20 +175,29 @@ describe("ensureAgentIsInitialized", () => {
|
||||
|
||||
describe("refreshAgent", () => {
|
||||
it("fetches a bounded projected tail after refreshing the agent", async () => {
|
||||
const client = makeClient();
|
||||
const client = new FakeDaemonClient();
|
||||
const runtime = new FakeTimelineRuntime();
|
||||
useSessionStore.getState().initializeSession(serverId, client as never);
|
||||
|
||||
await refreshAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: bindSetAgentInitializing(),
|
||||
});
|
||||
|
||||
expect(client.refreshAgent).toHaveBeenCalledWith(agentId);
|
||||
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
});
|
||||
expect(client.refreshedAgentIds).toEqual([agentId]);
|
||||
expect(runtime.requests).toEqual([
|
||||
{
|
||||
serverId,
|
||||
agentId,
|
||||
request: {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
rejectInitDeferred,
|
||||
refreshInitTimeout,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -37,6 +38,7 @@ export interface EnsureAgentIsInitializedInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
|
||||
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
|
||||
setAgentInitializing: SetAgentInitializing;
|
||||
hostDisconnectedMessage?: string;
|
||||
}
|
||||
@@ -68,7 +70,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => {
|
||||
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
@@ -77,14 +79,16 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
}
|
||||
|
||||
export interface RefreshAgentInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
client: Pick<DaemonClient, "refreshAgent" | "fetchAgentTimeline"> | null;
|
||||
client: Pick<DaemonClient, "refreshAgent"> | null;
|
||||
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
|
||||
setAgentInitializing: SetAgentInitializing;
|
||||
hostDisconnectedMessage?: string;
|
||||
}
|
||||
|
||||
export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
|
||||
const { agentId, client, setAgentInitializing } = input;
|
||||
const { serverId, agentId, client, runtime, setAgentInitializing } = input;
|
||||
if (!client) {
|
||||
throw new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
@@ -92,7 +96,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
|
||||
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
await client.fetchAgentTimeline(agentId, planTimelineTailFetch());
|
||||
await runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch());
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
throw error;
|
||||
@@ -135,6 +139,7 @@ export function useAgentInitialization({
|
||||
serverId,
|
||||
agentId,
|
||||
client,
|
||||
runtime: getHostRuntimeStore(),
|
||||
setAgentInitializing,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
@@ -144,12 +149,14 @@ export function useAgentInitialization({
|
||||
const refreshAgentCallback = useCallback(
|
||||
(agentId: string): Promise<void> =>
|
||||
refreshAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
client,
|
||||
runtime: getHostRuntimeStore(),
|
||||
setAgentInitializing,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[client, setAgentInitializing, t],
|
||||
[client, serverId, setAgentInitializing, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ToastApi } from "@/components/toast-host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store";
|
||||
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export interface LoadOlderAgentHistoryClient {
|
||||
fetchAgentTimeline: (
|
||||
@@ -97,7 +98,12 @@ export function useLoadOlderAgentHistory({
|
||||
const loadOlder = useCallback(() => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
void loadOlderAgentHistory(agentId, {
|
||||
client: (session?.client ?? null) as LoadOlderAgentHistoryClient | null,
|
||||
client: session?.client
|
||||
? {
|
||||
fetchAgentTimeline: (timelineAgentId, request) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
|
||||
}
|
||||
: null,
|
||||
cursor: session?.agentTimelineCursor.get(agentId),
|
||||
hasOlder: session?.agentTimelineHasOlder.get(agentId) === true,
|
||||
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
|
||||
|
||||
Reference in New Issue
Block a user