From 08a0aa8f69133e5b6f6b6e0e99e60ec6dd122884 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Jul 2026 11:53:04 +0200 Subject: [PATCH] fix directory bootstrap reconciliation edge cases --- packages/app/src/contexts/session-context.tsx | 21 ++++--- .../session-resume-revalidation.test.ts | 41 +++++++++++++ .../contexts/session-resume-revalidation.ts | 17 ++++++ .../agent-directory-reconciliation.test.ts | 20 +++++++ .../utils/agent-directory-reconciliation.ts | 4 +- packages/server/src/server/session.ts | 2 + .../src/server/session.workspaces.test.ts | 58 +++++++++++++++++++ 7 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 packages/app/src/contexts/session-resume-revalidation.test.ts create mode 100644 packages/app/src/contexts/session-resume-revalidation.ts diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index cdd80dcd3..0199b2cfa 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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) diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts new file mode 100644 index 000000000..80b3fd25f --- /dev/null +++ b/packages/app/src/contexts/session-resume-revalidation.test.ts @@ -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([]); + }); +}); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts new file mode 100644 index 000000000..23aa80301 --- /dev/null +++ b/packages/app/src/contexts/session-resume-revalidation.ts @@ -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; + hydrateWorkspaces: () => Promise; +}): Promise { + if (input.awayMs < SESSION_STALE_AFTER_MS) { + return false; + } + + input.bumpHistorySyncGeneration(input.serverId); + await Promise.all([input.refreshAgentDirectory(), input.hydrateWorkspaces()]); + return true; +} diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index 7fa313c04..03387d010 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -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(), diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts index ecbd936af..86130f5d2 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -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); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f3a666fc5..de82f0f60 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 04ccbd14b..99ae1169f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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((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(