Keep an archived worktree restorable after a failed re-import (#5238)

* fix(server): keep a workspace's placement when its directory is gone

Resolving a workspace read the checkout at its cwd and reconciled the
recorded placement against it. A git read answers "not a checkout" for a
plain directory and for a directory that is not there, so an archived
worktree whose directory had been removed came back as kind "directory"
with branch, worktreeRoot and mainRepoRoot null.

That rewrite happens before the import operation runs, and the failure
path only rolls back a workspace the import created, so a failed
re-import left the record erased. Workspace recovery can only recreate a
missing directory while the record still says "worktree" with a branch,
so the archived worktree stopped being restorable from Paseo and
reported workspace_directory_missing instead.

Placement is now reconciled only against a directory that is there. The
absence is resolved once, where the workspace directory is read, and
reconcileWorkspacePlacement refuses to rewrite from it, so the active
refresh path is covered by the same rule. The workspace reconciliation
pass already skipped missing directories for this reason.

Closes #5111

* fix(server): treat a directory lost mid-read as an absence

A worktree removed or unmounted while the git read is in flight makes
that read report "not a checkout", which erased the placement the same
way a directory that was already gone did. The read now counts as
evidence only while the directory is there on both sides of it.

---------

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 05:46:08 +02:00
committed by GitHub
parent 81ad00790f
commit 5303313632
10 changed files with 218 additions and 11 deletions
@@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createTestLogger(),
});
const workspaceAutoName = new WorkspaceAutoName({
+2 -1
View File
@@ -2,7 +2,7 @@ import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
import express from "express";
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
import { constants, existsSync, unlinkSync } from "fs";
import { open, rm } from "fs/promises";
import { open, rm, stat } from "fs/promises";
import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os";
import path from "node:path";
@@ -891,6 +891,7 @@ export async function createPaseoDaemon(
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async (target) => (await stat(target).catch(() => null))?.isDirectory() ?? false,
logger,
});
const agentProviderRuntime = await createAgentProviderRuntime({
@@ -1117,6 +1117,7 @@ function createDeps(options?: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createTestLogger(),
});
@@ -212,6 +212,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
});
return {
workspaceRegistry,
+1
View File
@@ -902,6 +902,7 @@ export class Session {
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
isDirectory: (path) => this.filesystem.isDirectory(path),
logger: this.sessionLogger,
});
this.workspaceRecovery = createWorkspaceRecoveryService({
@@ -1,9 +1,10 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getCheckoutStatus } from "../../../utils/checkout-git.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
createNoGitWorkspaceRuntimeSnapshot,
@@ -16,6 +17,7 @@ import {
createPersistedWorkspaceRecord,
type WorkspaceRegistry,
} from "../../workspace-registry.js";
import { checkoutLiteFromGitSnapshot } from "../../workspace-registry-model.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import {
createWorkspaceProvisioningService,
@@ -31,6 +33,15 @@ const logger = createTestLogger();
const ARCHIVED_AT = "2026-01-01T00:00:00.000Z";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
// The real filesystem, so "this directory is gone" is observed rather than modelled.
async function isDirectory(target: string): Promise<boolean> {
try {
return statSync(target).isDirectory();
} catch {
return false;
}
}
let tmpDir: string;
let gitRoots: Set<string>;
let gitBranches: Map<string, string | null>;
@@ -106,6 +117,7 @@ beforeEach(async () => {
workspaceRegistry,
projectRegistry,
workspaceGitService: gitService(),
isDirectory,
logger,
});
});
@@ -161,6 +173,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
gitRoots.add(repo);
@@ -187,6 +200,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
isDirectory,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async () => ({
@@ -224,6 +238,7 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
test("reopening archived exact-root records restores the fresh Git project", async () => {
const cwd = path.join(tmpDir, "repo");
mkdirSync(cwd, { recursive: true });
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: cwd,
kind: "non_git",
@@ -249,6 +264,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
const archivedProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
isDirectory,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async () => ({
@@ -279,6 +295,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
test("uses one workspace snapshot when reopening an archived workspace", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
@@ -300,6 +317,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
workspaceRegistry: snapshotRegistry,
projectRegistry,
workspaceGitService: gitService(),
isDirectory,
});
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
@@ -310,6 +328,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
test("reopening an archived workspace refreshes placement without renaming it", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
@@ -430,6 +449,7 @@ test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged
test("does not unarchive either record when checkout refresh fails", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
@@ -767,3 +787,136 @@ test.each(["missing", "archived"] as const)(
expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []);
},
);
test("a failed import keeps an archived worktree's placement when its directory is gone", async () => {
// The git port here is the production read: getCheckoutStatus against the real
// filesystem, so "the worktree directory is gone" is observed, not modelled.
const mainRepoRoot = path.join(tmpDir, "main-repo");
mkdirSync(mainRepoRoot, { recursive: true });
const worktreeCwd = path.join(tmpDir, "worktrees", "feature-example");
const project = await projectRegistry.getOrCreateActiveByRoot({
rootPath: mainRepoRoot,
kind: "git",
displayName: "main-repo",
timestamp: ARCHIVED_AT,
});
const archived = createPersistedWorkspaceRecord({
workspaceId: "ws-archived-worktree",
projectId: project.projectId,
cwd: worktreeCwd,
kind: "worktree",
displayName: "feature/example",
branch: "feature/example",
worktreeRoot: worktreeCwd,
baseBranch: "main",
isPaseoOwnedWorktree: true,
mainRepoRoot,
createdAt: ARCHIVED_AT,
updatedAt: ARCHIVED_AT,
archivedAt: ARCHIVED_AT,
});
await workspaceRegistry.upsert(archived);
const realCheckoutProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
logger,
isDirectory,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async (cwd: string) => {
const status = await getCheckoutStatus(cwd, { logger });
return checkoutLiteFromGitSnapshot(
cwd,
status.isGit
? {
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
repoRoot: status.repoRoot,
isPaseoOwnedWorktree: status.isPaseoOwnedWorktree,
mainRepoRoot: status.mainRepoRoot,
}
: {
isGit: false,
currentBranch: null,
remoteUrl: null,
repoRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
);
},
}),
});
await expect(
realCheckoutProvisioning.runInImportWorkspace({ cwd: worktreeCwd }, async () => {
throw new Error("provider resume failed");
}),
).rejects.toThrow("provider resume failed");
expect(await workspaceRegistry.get(archived.workspaceId)).toMatchObject({
kind: "worktree",
branch: "feature/example",
worktreeRoot: worktreeCwd,
mainRepoRoot,
isPaseoOwnedWorktree: true,
});
});
test("re-opening an active worktree workspace keeps its placement while the directory is away", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
gitBranches.set(repo, "feature/away");
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/away" });
rmSync(repo, { recursive: true, force: true });
gitRoots.delete(repo);
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(reopened).toMatchObject({
workspaceId: created.workspaceId,
kind: "local_checkout",
branch: "feature/away",
});
});
test("a directory that goes away while the git read is in flight keeps its placement", async () => {
const repo = path.join(tmpDir, "repo");
mkdirSync(repo, { recursive: true });
gitRoots.add(repo);
gitBranches.set(repo, "feature/vanishing");
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" });
const vanishingProvisioning = createWorkspaceProvisioningService({
workspaceRegistry,
projectRegistry,
isDirectory,
logger,
workspaceGitService: createNoopWorkspaceGitService({
peekSnapshot: () => null,
getCheckout: async (cwd: string) => {
rmSync(repo, { recursive: true, force: true });
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
},
}),
});
const reopened = await vanishingProvisioning.findOrCreateWorkspaceForDirectory(repo);
expect(reopened).toMatchObject({
workspaceId: created.workspaceId,
kind: "local_checkout",
branch: "feature/vanishing",
});
});
@@ -1,6 +1,7 @@
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
import { basename, resolve } from "node:path";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type { Logger } from "pino";
import {
generateWorkspaceId,
@@ -94,11 +95,27 @@ export function createWorkspaceProvisioningService(deps: {
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
isDirectory: (path: string) => Promise<boolean>;
logger: Logger;
lifecycle?: PluginLifecycle;
}): WorkspaceProvisioningService {
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
/**
* Placement facts at a workspace directory, or null when there is nothing
* there to read. A git read answers "not a checkout" for a plain directory
* and for a directory that is gone, and only the first is evidence that a
* worktree stopped being one. That answer therefore counts only while the
* directory is there on both sides of the read, so a worktree removed or
* unmounted while the read is in flight stays an absence.
*/
async function observeWorkspaceCheckout(cwd: string): Promise<ProjectCheckoutLitePayload | null> {
if (!(await deps.isDirectory(cwd))) return null;
const checkout = await workspaceGitService.getCheckout(cwd);
if (!checkout.isGit && !(await deps.isDirectory(cwd))) return null;
return checkout;
}
async function runInImportWorkspace<T>(
input: ImportWorkspaceInput,
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
@@ -366,12 +383,12 @@ export function createWorkspaceProvisioningService(deps: {
const timestamp = new Date().toISOString();
const checkout =
workspace.archivedAt || project.archivedAt
? await workspaceGitService.getCheckout(workspace.cwd)
? await observeWorkspaceCheckout(workspace.cwd)
: null;
const autoArchivedChangeRequestUrl =
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
let next: PersistedWorkspaceRecord | null = null;
if (workspace.archivedAt && checkout) {
if (workspace.archivedAt) {
const placementUpdate = reconcileWorkspacePlacement({
workspace,
checkout,
@@ -384,10 +401,11 @@ export function createWorkspaceProvisioningService(deps: {
updatedAt: timestamp,
};
}
if (checkout && (project.archivedAt || workspace.archivedAt)) {
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
if (project.archivedAt || workspace.archivedAt) {
const projectCheckout =
checkout && areEquivalentPaths(project.rootPath, workspace.cwd)
? checkout
: await workspaceGitService.getCheckout(project.rootPath);
const kind = projectCheckout.isGit ? "git" : "non_git";
const projectKey = deriveProjectKey({
rootPath: project.rootPath,
@@ -414,10 +432,10 @@ export function createWorkspaceProvisioningService(deps: {
async function refreshWorkspaceRecord(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
const checkout = await observeWorkspaceCheckout(workspace.cwd);
const project = await projectRegistry.get(workspace.projectId);
if (project && !project.archivedAt) {
await refreshProjectKind(project, workspace.cwd, checkout);
await refreshProjectKind(project, workspace.cwd, checkout ?? undefined);
}
const update = reconcileWorkspacePlacement({
workspace,
@@ -87,6 +87,31 @@ describe("workspace placement", () => {
});
});
test("keeps the recorded placement when nothing was read at the workspace directory", () => {
const workspace = createPersistedWorkspaceRecord({
workspaceId: "workspace-one",
projectId: "project-one",
cwd: "/repo-feature",
kind: "worktree",
displayName: "feature/placement",
branch: "feature/placement",
worktreeRoot: "/repo-feature",
baseBranch: "main",
isPaseoOwnedWorktree: true,
mainRepoRoot: "/repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
const update = reconcileWorkspacePlacement({
workspace,
checkout: null,
updatedAt: "2026-03-02T00:00:00.000Z",
});
expect(update).toBeNull();
});
test("updates live placement while preserving its durable name and base branch", () => {
const workspace = createPersistedWorkspaceRecord({
workspaceId: "workspace-one",
@@ -109,12 +109,17 @@ export function initialWorkspacePlacement(
/**
* Applies live placement facts without rewriting the workspace's durable name
* or its creation-time base branch.
*
* A null checkout is an absence, not an observation: nothing was read at the
* workspace directory, so the recorded placement stands. Only a directory that
* is there can show that a worktree stopped being one.
*/
export function reconcileWorkspacePlacement(input: {
workspace: PersistedWorkspaceRecord;
checkout: ProjectCheckoutLitePayload;
checkout: ProjectCheckoutLitePayload | null;
updatedAt: string;
}): WorkspacePlacementUpdate | null {
if (!input.checkout) return null;
const observed = initialWorkspacePlacement({
source: "checkout",
cwd: input.workspace.cwd,
@@ -366,6 +366,7 @@ function createPaseoWorktreeForTest(options: {
projectRegistry,
workspaceRegistry,
workspaceGitService,
isDirectory: async () => true,
logger: createLogger(),
});