fix superseded directory sync races

This commit is contained in:
Mohamed Boudra
2026-07-17 13:44:50 +02:00
parent 7d838a0dfb
commit 1774ce3c4c
7 changed files with 123 additions and 5 deletions

View File

@@ -78,6 +78,8 @@ import {
} from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
import { inheritBufferedDirectoryDeltas } from "@/utils/buffered-directory-transaction";
import { shouldApplyTimelineAgentSnapshot } from "@/utils/agent-directory-sync";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -592,7 +594,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
id: Symbol("workspace hydration"),
client,
workspaces: new Map(),
deltas: [],
deltas: inheritBufferedDirectoryDeltas({
client,
previous: workspaceHydrationRef.current,
}),
};
workspaceHydrationRef.current = transaction;
let snapshot: WorkspaceHydrationSnapshot | null;
@@ -978,7 +983,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
return next;
});
if (payload.agent) {
if (payload.agent && shouldApplyTimelineAgentSnapshot(serverId, agentId)) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot(
applyLegacyDaemonWorkspaceOwnership({

View File

@@ -229,16 +229,22 @@ function makeFetchAgentsPayload(input: {
class Deferred<T> {
readonly promise: Promise<T>;
private resolvePromise!: (value: T) => void;
private rejectPromise!: (error: Error) => void;
constructor() {
this.promise = new Promise((resolve) => {
this.promise = new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
}
resolve(value: T): void {
this.resolvePromise(value);
}
reject(error: Error): void {
this.rejectPromise(error);
}
}
async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise<void> {
@@ -1967,6 +1973,58 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("replays inherited deltas when a superseding refresh fails", async () => {
const host = makeHost({ serverId: "srv_overlap_failure" });
const fakeClient = new FakeDaemonClient();
fakeClient.setConnectionState({ status: "connected" });
fakeClient.fetchAgentsResponses.push(makeFetchAgentsPayload({ entries: [] }));
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_overlap_failure",
},
});
useSessionStore
.getState()
.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
store.syncHosts([host]);
await fakeClient.waitForFetches(1);
await waitForDirectoryReady(store, host.serverId);
const olderPage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(olderPage.promise);
const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(2);
const liveEntry = makeFetchAgentsEntry({
id: "live-delta",
cwd: "/repo",
updatedAt: "2026-07-17T10:00:00.000Z",
title: "preserved",
});
fakeClient.agentUpdate({ kind: "upsert", agent: liveEntry.agent, project: liveEntry.project });
const newerPage = new Deferred<Awaited<ReturnType<DaemonClient["fetchAgents"]>>>();
fakeClient.fetchAgentsResponses.push(newerPage.promise);
const newerRefresh = store.refreshAgentDirectory({ serverId: host.serverId });
await fakeClient.waitForFetches(3);
newerPage.reject(new Error("newer refresh failed"));
await expect(newerRefresh).rejects.toThrow("newer refresh failed");
expect(
useSessionStore.getState().sessions[host.serverId]?.agents.get("live-delta")?.title,
).toBe("preserved");
olderPage.resolve(makeFetchAgentsPayload({ entries: [] }));
await expect(olderRefresh).rejects.toThrow();
store.syncHosts([]);
useSessionStore.getState().clearSession(host.serverId);
});
it("rejects a refresh when the session generation changes before commit", async () => {
const host = makeHost({ serverId: "srv_stale_generation" });
const fakeClient = new FakeDaemonClient();

View File

@@ -67,6 +67,7 @@ import {
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { inheritBufferedDirectoryDeltas } from "@/utils/buffered-directory-transaction";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -2245,12 +2246,16 @@ export class HostRuntimeStore {
throw new Error(`Host ${input.serverId} is not connected`);
}
const previousTransaction = this.agentDirectoryTransactions.get(input.serverId);
const transaction: AgentDirectoryTransaction = {
id: Symbol("agent directory refresh"),
client,
clientGeneration: snapshot.clientGeneration,
entries: [],
deltas: [],
deltas:
previousTransaction?.clientGeneration === snapshot.clientGeneration
? inheritBufferedDirectoryDeltas({ client, previous: previousTransaction })
: [],
};
this.agentDirectoryTransactions.set(input.serverId, transaction);

View File

@@ -7,7 +7,11 @@ import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
import {
applyAgentDirectoryDelta,
replaceFetchedAgentDirectory,
shouldApplyTimelineAgentSnapshot,
} from "./agent-directory-sync";
function createAgentPayload(
input: Partial<Omit<AgentSnapshotPayload, "labels">> & {
@@ -141,6 +145,7 @@ describe("replaceFetchedAgentDirectory", () => {
serverId,
new Map([["permission", { key: "permission", agentId, request: null as never }]]),
);
store.setInitializingAgents(serverId, new Map([[agentId, true]]));
setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true });
applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } });
@@ -152,6 +157,8 @@ describe("replaceFetchedAgentDirectory", () => {
queued: session?.queuedMessages.has(agentId),
cursor: session?.agentTimelineCursor.has(agentId),
permissions: session?.pendingPermissions.size,
initializing: session?.initializingAgents.has(agentId),
acceptsStaleTimelineSnapshot: shouldApplyTimelineAgentSnapshot(serverId, agentId),
archivePending: isAgentArchiving({ queryClient, serverId, agentId }),
}).toEqual({
agents: false,
@@ -159,6 +166,8 @@ describe("replaceFetchedAgentDirectory", () => {
queued: false,
cursor: false,
permissions: 0,
initializing: false,
acceptsStaleTimelineSnapshot: false,
archivePending: false,
});

View File

@@ -24,6 +24,11 @@ export function applyAgentDirectoryDelta(input: { serverId: string; delta: Agent
return upsertAgentDirectoryReplica(input.serverId, input.delta);
}
export function shouldApplyTimelineAgentSnapshot(serverId: string, agentId: string): boolean {
const session = useSessionStore.getState().sessions[serverId];
return session?.agents.has(agentId) === true || session?.initializingAgents.get(agentId) === true;
}
type AgentUpsertDelta = Extract<AgentDirectoryDelta, { kind: "upsert" }>;
function upsertAgentDirectoryReplica(
@@ -100,6 +105,7 @@ function removeAgentDirectoryReplica(serverId: string, agentId: string): void {
store.setAgentDetails(serverId, removeKey);
store.setQueuedMessages(serverId, removeKey);
store.setAgentTimelineCursor(serverId, removeKey);
store.setInitializingAgents(serverId, removeKey);
store.setPendingPermissions(serverId, (current) => {
const next = new Map(current);
for (const [key, pending] of next) {

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { inheritBufferedDirectoryDeltas } from "./buffered-directory-transaction";
describe("inheritBufferedDirectoryDeltas", () => {
it("hands buffered updates to a superseding transaction for the same client", () => {
const client = {};
const previous = { client, deltas: ["first"] };
const inherited = inheritBufferedDirectoryDeltas({ client, previous });
previous.deltas.push("later");
expect(inherited).toEqual(["first"]);
});
it("does not carry updates across clients", () => {
expect(
inheritBufferedDirectoryDeltas({
client: {},
previous: { client: {}, deltas: ["stale"] },
}),
).toEqual([]);
});
});

View File

@@ -0,0 +1,12 @@
export interface BufferedDirectoryTransaction<TClient, TDelta> {
client: TClient;
deltas: TDelta[];
}
export function inheritBufferedDirectoryDeltas<TClient, TDelta>(input: {
client: TClient;
previous: BufferedDirectoryTransaction<TClient, TDelta> | null | undefined;
}): TDelta[] {
if (input.previous?.client !== input.client) return [];
return [...input.previous.deltas];
}