From 3a36cc33c6796e1d88deb656da2210147d918685 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 00:45:12 +0200 Subject: [PATCH] Keep workspaces on a disk or share that is not mounted (#5227) * fix(server): keep workspaces whose project root is unreachable Reconciliation archived every active workspace whose cwd returned ENOENT on a single stat, including the pass that runs at daemon startup. An external disk that has not mounted yet, or a network share that is briefly down, made every workspace on it disappear from the sidebar along with its sessions, and nothing brought them back when the directory returned. A missing path is only evidence that a workspace was removed when the project it belongs to is still readable. When the project root is missing too, the whole location is unavailable, and projects already persist through that. Workspaces now follow the same rule. Closes #4982 * fix(server): read project roots after workspace directories Reading the project roots first left a window where a volume could go away between the two reads, marking a project reachable while its workspace looked missing. Reading them after inverts the skew: a volume that disappears mid-pass makes the project unreachable, which withholds an archive instead of causing one. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- ...rkspace-reconciliation-observation.test.ts | 21 ++++- .../workspace-reconciliation-service.test.ts | 87 ++++++++++++++++--- .../workspace-reconciliation-service.ts | 21 ++++- 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index a5fa20c97f..8977641bab 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { areEquivalentPaths } from "../utils/path.js"; +import { deriveProjectKey } from "./project-key.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -336,6 +337,14 @@ class ObservedPlacements { projectId: spec.id, rootPath, kind: "non_git", + // Seeded records already carry the key reconciliation derives, so a fixture's + // first pass reports only what its test is about. + projectKey: deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), displayName: spec.id, createdAt: TIMESTAMP, updatedAt: TIMESTAMP, @@ -473,7 +482,11 @@ describe("observed workspace placement", () => { test("archives missing workspace directories on the periodic pass", async () => { const observed = new ObservedPlacements([ - { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + { + id: "project-one", + root: "repo", + workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }], + }, ]); await observed.start(); await observed.deleteWorkspaceDirectory("workspace-one"); @@ -487,7 +500,11 @@ describe("observed workspace placement", () => { test("preserves a periodic full pass queued behind metadata reconciliation", async () => { const observed = new ObservedPlacements([ - { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + { + id: "project-one", + root: "repo", + workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }], + }, ]); await observed.start(); const metadataRead = observed.holdNextReconciliation(); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 1a885378db..1bceb38715 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -598,6 +598,11 @@ describe("WorkspaceReconciliationService", () => { }); test("archives workspaces whose directories no longer exist", async () => { + const projectRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-missing-workspace-")), + ); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const archivedWorkspaceIds: string[] = []; @@ -605,7 +610,8 @@ describe("WorkspaceReconciliationService", () => { "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -617,7 +623,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -640,7 +646,7 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-reconcile-test", + directory: missingWorkspace, reason: "directory_missing", }, ]); @@ -648,12 +654,59 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); }); + test("keeps workspaces whose project root is missing with them", async () => { + const mountParent = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-unmounted-"))); + tempDirs.push(mountParent); + // The external volume is not mounted, so nothing under it resolves. + const projectRoot = path.join(mountParent, "ExternalSSD", "repo"); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: projectRoot, + kind: "directory", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBeNull(); + }); + test("keeps a project active after all its workspaces are archived", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-orphan-project-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const project = createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-orphan", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "orphan", createdAt: timestamp, @@ -665,7 +718,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-orphan", + cwd: missingWorkspace, kind: "directory", displayName: "orphan", createdAt: timestamp, @@ -685,14 +738,14 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-reconcile-orphan", + directory: missingWorkspace, reason: "directory_missing", }, ]); expect(workspaces.get("w1")).toEqual({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-orphan", + cwd: missingWorkspace, kind: "directory", displayName: "orphan", title: null, @@ -1382,13 +1435,17 @@ describe("WorkspaceReconciliationService", () => { }); test("calls onChanges callback when changes are applied", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-callback-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); projects.set( "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-callback-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -1400,7 +1457,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-callback-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -1422,13 +1479,16 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-callback-test", + directory: missingWorkspace, reason: "directory_missing", }, ]); }); test("logs reconciliation changes with affected paths and reasons", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-log-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const { logger, infoRecords } = createCapturingLogger(); @@ -1436,7 +1496,8 @@ describe("WorkspaceReconciliationService", () => { "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-log-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -1448,7 +1509,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-log-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -1473,7 +1534,7 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-log-test", + directory: missingWorkspace, reason: "directory_missing", }, ]), diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 59d2f9c124..eb79dbacd2 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -227,6 +227,14 @@ export class WorkspaceReconciliationService { workspace, state: this.inspectDirectory(workspace.cwd), })); + // Project roots are read after the workspace directories, so a volume that + // goes away mid-pass leaves its project unreachable rather than its workspaces + // alone. The skew can only withhold an archive, never produce one. + const reachableProjectIds = new Set( + activeProjects + .filter((project) => this.inspectDirectory(project.rootPath) === "directory") + .map((project) => project.projectId), + ); const workspacesByProject = new Map(); for (const { workspace, state } of workspaceDirectoryStates) { @@ -236,9 +244,16 @@ export class WorkspaceReconciliationService { workspacesByProject.set(workspace.projectId, list); } - // 1. Archive workspaces whose directories no longer exist + // 1. Archive workspaces whose directories no longer exist, but only when the + // project they belong to is still reachable. A missing project root means the + // whole location is unavailable - an unmounted volume, an offline share, a disk + // that has not appeared yet - and absence there proves nothing about the + // workspace. Projects already persist through that; their workspaces do too. const missingWorkspaces = workspaceDirectoryStates - .filter(({ state }) => state === "missing") + .filter( + ({ workspace, state }) => + state === "missing" && reachableProjectIds.has(workspace.projectId), + ) .map(({ workspace }) => workspace); await Promise.all( missingWorkspaces.map(async (workspace) => { @@ -265,7 +280,7 @@ export class WorkspaceReconciliationService { // Projects persist until explicitly removed, even when they currently have // zero active workspaces, so they still reconcile their own metadata. await this.reconcileGitMetadataForProjects( - activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"), + activeProjects.filter((project) => reachableProjectIds.has(project.projectId)), workspacesByProject, changes, );