From c1558d29eab78c898743adb613afa01ba4f0e780 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 18:48:13 +0700 Subject: [PATCH] fix(app): keep background workspaces hidden after reload (#7141) * fix(app): preserve background workspace visibility after reload Persist the background flag and reject workspace rows that predate it so the existing cache repair clears their workspace cursor and refreshes the baseline. * test(app): make legacy workspace cache read paths explicit --- .../src/runtime/replica-cache/index.test.ts | 86 +++++++++++++++++++ .../app/src/runtime/replica-cache/index.ts | 3 + 2 files changed, 89 insertions(+) diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 15adc841fb..c48777a3e2 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -255,7 +255,93 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void { ]); } +async function cacheWithoutBackgroundVisibility() { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const checkpoint = { + agents: { generation: "g", afterSeq: 12 }, + projects: { generation: "g", afterSeq: 4 }, + workspaces: { generation: "g", afterSeq: 8 }, + }; + const baseline = directory(checkpoint); + commitDirectory(writer, SERVER_ID, baseline); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + const key = `${SERVER_ID}:workspace:workspace-1`; + const row = storage.rows.get(key)!; + const payload = JSON.parse(row.payload) as Record; + delete payload.background; + storage.rows.set(key, { ...row, payload: JSON.stringify(payload) }); + + return { storage, baseline, reader: createCache(storage) }; +} + +async function expectLegacyWorkspaceRefresh({ + storage, + baseline, + reader, +}: Awaited>): Promise { + const restored = await reader.readDirectory(SERVER_ID); + expect(restored.workspaces.size).toBe(0); + expect(restored.checkpoint).toEqual({ + agents: baseline.checkpoint.agents, + projects: baseline.checkpoint.projects, + }); + expect(restored.agents).toEqual(baseline.agents); + expect(restored.projects).toEqual(baseline.projects); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline()); + expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual( + restored.checkpoint, + ); + + baseline.workspaces.set( + "workspace-1", + normalizeWorkspaceDescriptor({ ...workspacePayload(), background: true }), + ); + reader.replaceDirectoryBaseline(SERVER_ID, baseline); + await reader.flush(); + const refreshed = await createCache(storage).readDirectory(SERVER_ID); + expect(refreshed.workspaces.get("workspace-1")?.background).toBe(true); + expect(refreshed.checkpoint).toEqual(baseline.checkpoint); +} + describe("ReplicaCache", () => { + it.each([true, false, undefined])( + "preserves background visibility (%s) when reopening a cached workspace", + async (background) => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory(); + baseline.workspaces.set( + "workspace-1", + normalizeWorkspaceDescriptor({ ...workspacePayload(), background }), + ); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + + const reader = createCache(storage); + expect( + (await reader.readDirectory(SERVER_ID)).workspaces.get("workspace-1")?.background, + ).toBe(background ?? false); + expect((await reader.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.background).toBe( + background ?? false, + ); + }, + ); + + it("refetches workspaces cached without background visibility after a directory read", async () => { + const cached = await cacheWithoutBackgroundVisibility(); + + await expectLegacyWorkspaceRefresh(cached); + }); + + it("refetches workspaces cached without background visibility after a workspace read", async () => { + const cached = await cacheWithoutBackgroundVisibility(); + + expect(await cached.reader.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); + await expectLegacyWorkspaceRefresh(cached); + }); + it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 94937d4dc5..681d11a2c4 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -306,6 +306,8 @@ const StoredWorkspaceSchema = z.strictObject({ worktreeSlug: z.string().optional(), projectKind: z.enum(["git", "non_git", "directory"]), workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), + // Require the flag so older rows that lost background visibility invalidate the workspace cursor. + background: z.boolean(), name: z.string(), title: z.string().nullable(), pinnedAt: z.string().nullable(), @@ -698,6 +700,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { worktreeSlug: workspace.worktreeSlug, projectKind: workspace.projectKind, workspaceKind: workspace.workspaceKind, + background: workspace.background ?? false, name: workspace.name, title: workspace.title ?? null, pinnedAt: workspace.pinnedAt ?? null,