From 5303313632535f695f79f01f65db483977962cc3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:46:08 +0200 Subject: [PATCH] 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> --- .../src/server/agent/mcp-server.test.ts | 1 + packages/server/src/server/bootstrap.ts | 3 +- .../src/server/paseo-worktree-service.test.ts | 1 + .../src/server/schedule/service.test.ts | 1 + packages/server/src/server/session.ts | 1 + .../workspace-provisioning-service.test.ts | 155 +++++++++++++++++- .../workspace-provisioning-service.ts | 34 +++- .../server/workspace-registry-model.test.ts | 25 +++ .../src/server/workspace-registry-model.ts | 7 +- .../src/server/worktree-session.test.ts | 1 + 10 files changed, 218 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index c5b97ce978..5d09a51b1a 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createTestLogger(), }); const workspaceAutoName = new WorkspaceAutoName({ diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 380e3618e0..88090fdf14 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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({ diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 21f9951998..cbcef548e4 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1117,6 +1117,7 @@ function createDeps(options?: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createTestLogger(), }); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 322bffa8fb..224df7cd3f 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -212,6 +212,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, }); return { workspaceRegistry, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5dbcb63731..c69033ab3f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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({ diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 0dee0a08d0..b84734eb8b 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -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 { + try { + return statSync(target).isDirectory(); + } catch { + return false; + } +} + let tmpDir: string; let gitRoots: Set; let gitBranches: Map; @@ -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", + }); +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index ecd338ff4e..5149ed5d1c 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -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; + isDirectory: (path: string) => Promise; 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 { + 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( input: ImportWorkspaceInput, operation: (workspace: PersistedWorkspaceRecord) => Promise, @@ -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 { - 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, diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 1f51a0c4a1..a4ad29568c 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -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", diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index acc4d49538..181ec96eae 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -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, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 0fb32bf558..f28718c0f5 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -366,6 +366,7 @@ function createPaseoWorktreeForTest(options: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createLogger(), });