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>
This commit is contained in:
paseo-bot[bot]
2026-09-23 00:45:12 +02:00
committed by GitHub
parent 17c5050216
commit 3a36cc33c6
3 changed files with 111 additions and 18 deletions
@@ -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();
@@ -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",
},
]),
@@ -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<string, PersistedWorkspaceRecord[]>();
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,
);