fix directory bootstrap reconciliation edge cases

This commit is contained in:
Mohamed Boudra
2026-07-17 11:53:04 +02:00
parent bd737a30b0
commit 08a0aa8f69
7 changed files with 155 additions and 8 deletions

View File

@@ -76,6 +76,7 @@ import {
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -90,8 +91,6 @@ export type {
AgentFileExplorerState,
} from "@/stores/session-store";
const HISTORY_STALE_AFTER_MS = 60_000;
function hasAgentUsageChanged(
incomingUsage: Agent["lastUsage"] | undefined,
currentUsage: Agent["lastUsage"] | undefined,
@@ -755,12 +754,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const handleAppResumed = useCallback(
(awayMs: number) => {
if (awayMs < HISTORY_STALE_AFTER_MS) {
return;
}
bumpHistorySyncGeneration(serverId);
void revalidateSessionAfterResume({
awayMs,
serverId,
bumpHistorySyncGeneration,
refreshAgentDirectory: () => getHostRuntimeStore().refreshAgentDirectory({ serverId }),
hydrateWorkspaces,
}).catch((error) => {
console.error("[SessionProvider] resume revalidation failed", {
serverId,
error: toErrorMessage(error),
});
});
},
[bumpHistorySyncGeneration, serverId],
[bumpHistorySyncGeneration, hydrateWorkspaces, serverId],
);
// Client activity tracking (heartbeat, push token registration)

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
revalidateSessionAfterResume,
SESSION_STALE_AFTER_MS,
} from "./session-resume-revalidation";
describe("session resume revalidation", () => {
it("refreshes both directories and timeline history after a stale resume", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS,
serverId: "server",
bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`),
refreshAgentDirectory: async () => calls.push("agents"),
hydrateWorkspaces: async () => {
calls.push("workspaces");
},
});
expect(revalidated).toBe(true);
expect(calls).toEqual(["history:server", "agents", "workspaces"]);
});
it("does nothing after a brief background interval", async () => {
const calls: string[] = [];
const revalidated = await revalidateSessionAfterResume({
awayMs: SESSION_STALE_AFTER_MS - 1,
serverId: "server",
bumpHistorySyncGeneration: () => calls.push("history"),
refreshAgentDirectory: async () => calls.push("agents"),
hydrateWorkspaces: async () => {
calls.push("workspaces");
},
});
expect(revalidated).toBe(false);
expect(calls).toEqual([]);
});
});

View File

@@ -0,0 +1,17 @@
export const SESSION_STALE_AFTER_MS = 60_000;
export async function revalidateSessionAfterResume(input: {
awayMs: number;
serverId: string;
bumpHistorySyncGeneration: (serverId: string) => void;
refreshAgentDirectory: () => Promise<unknown>;
hydrateWorkspaces: () => Promise<void>;
}): Promise<boolean> {
if (input.awayMs < SESSION_STALE_AFTER_MS) {
return false;
}
input.bumpHistorySyncGeneration(input.serverId);
await Promise.all([input.refreshAgentDirectory(), input.hydrateWorkspaces()]);
return true;
}

View File

@@ -151,6 +151,26 @@ describe("agent directory reconciliation", () => {
}).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] });
});
it("clears a snapshot stop when a newer buffered upsert is running", () => {
const result = reconcileAgentDirectory({
previous: new Map([["agent", replica("agent", "running")]]),
snapshot: [entry("agent", "idle")],
deltas: [
{
kind: "upsert",
agent: {
...snapshot("agent", "running"),
updatedAt: "2026-07-12T11:00:00.000Z",
},
project: entry("agent", "running").project,
},
],
});
expect(result.entries[0]?.agent.status).toBe("running");
expect(result.stoppedRunningAgentIds).toEqual([]);
});
it("accepts usage from a stale buffered upsert without regressing metadata", () => {
const result = reconcileAgentDirectory({
previous: new Map(),

View File

@@ -28,7 +28,9 @@ export function reconcileAgentDirectory(input: {
}
const previousEntry = entries.get(delta.agent.id);
const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent);
if (statuses.get(delta.agent.id) === "running" && acceptedAgent.status !== "running") {
if (acceptedAgent.status === "running") {
stoppedRunningAgentIds.delete(delta.agent.id);
} else if (statuses.get(delta.agent.id) === "running") {
stoppedRunningAgentIds.add(delta.agent.id);
}
statuses.set(delta.agent.id, acceptedAgent.status);

View File

@@ -4067,6 +4067,8 @@ export class Session {
continue;
}
}
const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id;
subscription.lastEmittedByWorkspaceId.set(workspaceId, payload);
this.emit({
type: "workspace_update",
payload,

View File

@@ -6568,6 +6568,64 @@ test("subscribed fetch_workspaces includes git enrichment in the initial snapsho
);
});
test("a workspace leaving a filtered subscription after bootstrap emits a removal", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
);
const descriptor = {
id: "ws-buffered",
projectId: "proj-buffered",
projectDisplayName: "repo",
projectRootPath: REPO_CWD,
workspaceDirectory: REPO_CWD,
projectKind: "git" as const,
workspaceKind: "local_checkout" as const,
name: "repo work",
status: "done" as const,
activityAt: null,
diffStat: null,
};
let currentDescriptor: typeof descriptor | null = descriptor;
let finishListing: (result: ListFetchResult) => void = () => {};
const listing = new Promise<ListFetchResult>((resolve) => {
finishListing = resolve;
});
session.listFetchWorkspacesEntries = async () => listing;
session.buildWorkspaceDescriptorMap = async () =>
new Map(currentDescriptor ? [[currentDescriptor.id, currentDescriptor]] : []);
session.reconcileAndEmitWorkspaceUpdates = async () => undefined;
const bootstrap = session.handleMessage({
type: "fetch_workspaces_request",
requestId: "req-buffered-filter",
filter: { query: "repo" },
subscribe: { subscriptionId: "sub-buffered-filter" },
});
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true });
finishListing({
entries: [],
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
});
await bootstrap;
expect(filterByType(emitted, "workspace_update")).toEqual([
{ type: "workspace_update", payload: { kind: "upsert", workspace: descriptor } },
]);
emitted.length = 0;
currentDescriptor = { ...descriptor, name: "other work" };
await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id], { skipReconcile: true });
expect(filterByType(emitted, "workspace_update")).toEqual([
{
type: "workspace_update",
payload: { kind: "remove", id: descriptor.id },
},
]);
});
test("project.rename.request stores customName and emits an updated workspace descriptor", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = asTestSession(