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
This commit is contained in:
Mohamed Boudra
2026-10-16 18:48:13 +07:00
committed by GitHub
parent 8d9a7123ba
commit c1558d29ea
2 changed files with 89 additions and 0 deletions
@@ -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<string, unknown>;
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<ReturnType<typeof cacheWithoutBackgroundVisibility>>): Promise<void> {
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);
@@ -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,