fix(projects): preserve placement through workspace lifecycle

Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation.
This commit is contained in:
Mohamed Boudra
2026-07-17 08:51:48 +00:00
parent 634d69bf34
commit 63a2bce1d8
19 changed files with 617 additions and 532 deletions

View File

@@ -20,6 +20,9 @@ checkout root. They intentionally differ for an exact subproject inside a worktr
restore, branch auto-name, and descriptor flows consume those persisted facts rather than
rediscovering ownership from a directory that may already be gone. Reconciliation may refresh
mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`.
Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.

View File

@@ -216,8 +216,6 @@ export class CreateAgentLifecycleDispatch {
},
{
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
repoRoot: createdWorktree.repoRoot ?? ownership.repoRoot ?? null,
paseoWorktreesBaseRoot: this.dependencies.worktreesRoot,
requestId: randomUUID(),
},
);

View File

@@ -486,8 +486,6 @@ describe("archiveIfSafe", () => {
}),
{
scope: { kind: "workspace", workspaceId: "ws-auto-archive" },
repoRoot: "/tmp/repo",
paseoWorktreesBaseRoot: undefined,
requestId: "auto-archive-on-merge",
},
);

View File

@@ -138,8 +138,6 @@ export async function archiveIfSafe(input: {
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: ownership.repoRoot ?? null,
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
requestId: "auto-archive-on-merge",
},
);

View File

@@ -886,6 +886,7 @@ export async function createPaseoDaemon(
kind: workspace.kind,
worktreeRoot: workspace.worktreeRoot,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
mainRepoRoot: workspace.mainRepoRoot,
}));
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
@@ -1057,7 +1058,7 @@ export async function createPaseoDaemon(
await emitWorkspaceUpdatesExternal([result.workspace.workspaceId]);
return result;
};
const archiveScheduleWorkspaceExternal = async (workspaceId: string, repoRoot: string) => {
const archiveScheduleWorkspaceExternal = async (workspaceId: string) => {
await archiveByScope(
{
paseoHome: config.paseoHome,
@@ -1084,8 +1085,6 @@ export async function createPaseoDaemon(
},
{
scope: { kind: "workspace", workspaceId },
repoRoot,
paseoWorktreesBaseRoot: config.worktreesRoot,
requestId: "schedule-run-finish",
},
);

View File

@@ -138,7 +138,6 @@ function createScheduleService(options: TestScheduleServiceOptions): ScheduleSer
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: null,
requestId: "schedule-service-test",
},
);
@@ -242,7 +241,6 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
},
{
scope: { kind: "workspace", workspaceId },
repoRoot: null,
requestId: "schedule-service-test",
},
);
@@ -1906,7 +1904,7 @@ describe("ScheduleService", () => {
],
}));
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
const archiveCalls: string[] = [];
now = new Date("2026-01-01T00:10:00.000Z");
const service2 = createScheduleService({
paseoHome: tempDir,
@@ -1916,13 +1914,13 @@ describe("ScheduleService", () => {
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
archiveWorkspace: async (archivedWorkspaceId) => {
archiveCalls.push(archivedWorkspaceId);
},
});
await service2.start();
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
expect(archiveCalls).toEqual([workspaceId]);
const inspected = await service2.inspect(created.id);
expect(inspected.runs[0]).toMatchObject({
status: "failed",
@@ -1974,7 +1972,7 @@ describe("ScheduleService", () => {
],
}));
const archiveCalls: Array<{ workspaceId: string; repoRoot: string }> = [];
const archiveCalls: string[] = [];
now = new Date("2026-01-01T00:10:00.000Z");
const service2 = createScheduleService({
paseoHome: tempDir,
@@ -1984,13 +1982,13 @@ describe("ScheduleService", () => {
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "ok" }),
archiveWorkspace: async (archivedWorkspaceId, repoRoot) => {
archiveCalls.push({ workspaceId: archivedWorkspaceId, repoRoot });
archiveWorkspace: async (archivedWorkspaceId) => {
archiveCalls.push(archivedWorkspaceId);
},
});
await service2.start();
expect(archiveCalls).toEqual([{ workspaceId, repoRoot: tempDir }]);
expect(archiveCalls).toEqual([workspaceId]);
const inspected = await service2.inspect(created.id);
expect(inspected.runs[0]).toMatchObject({
status: "failed",

View File

@@ -224,7 +224,7 @@ export interface ScheduleServiceOptions {
createPaseoWorktreeWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
archiveWorkspace: (workspaceId: string) => Promise<void>;
now?: () => Date;
runner?: (schedule: StoredSchedule, runId: string) => Promise<ScheduleExecutionResult>;
}
@@ -241,7 +241,7 @@ export class ScheduleService {
private readonly createPaseoWorktreeWorkspace: (
input: ScheduleWorkspaceCreateInput,
) => Promise<CreatePaseoWorktreeWorkflowResult>;
private readonly archiveWorkspace: (workspaceId: string, repoRoot: string) => Promise<void>;
private readonly archiveWorkspace: (workspaceId: string) => Promise<void>;
private readonly now: () => Date;
private readonly runner: (
schedule: StoredSchedule,
@@ -579,7 +579,6 @@ export class ScheduleService {
private async recoverInterruptedSchedule(scheduleId: string, now: Date): Promise<void> {
const interruptedWorkspaces: Array<{
workspaceId: string;
repoRoot: string;
agentId: string | null;
runId: string;
}> = [];
@@ -601,7 +600,6 @@ export class ScheduleService {
) {
interruptedWorkspaces.push({
workspaceId: runningRun.workspaceId,
repoRoot: updated.target.config.cwd,
agentId: runningRun.agentId,
runId: runningRun.id,
});
@@ -639,7 +637,7 @@ export class ScheduleService {
return;
}
try {
await this.archiveWorkspace(interruptedWorkspace.workspaceId, interruptedWorkspace.repoRoot);
await this.archiveWorkspace(interruptedWorkspace.workspaceId);
} catch (error) {
this.logger.warn(
{
@@ -930,7 +928,7 @@ export class ScheduleService {
shouldArchiveScheduleRunWorkspace({ agentId, archiveOnFinish: config.archiveOnFinish })
) {
try {
await this.archiveWorkspace(workspace.workspaceId, config.cwd);
await this.archiveWorkspace(workspace.workspaceId);
} catch (error) {
this.logger.warn(
{

View File

@@ -1,7 +1,7 @@
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
import { basename, resolve, sep } from "path";
import { resolve, sep } from "path";
import { homedir } from "node:os";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import {
@@ -179,7 +179,7 @@ import {
matchesAgentUpdatesFilter,
type AgentUpdatesService,
} from "./session/agent-updates/agent-updates-service.js";
import { createRealpathAwarePathMatcher, expandTilde } from "../utils/path.js";
import { expandTilde } from "../utils/path.js";
import {
searchDirectoryEntries,
WORKSPACE_SEARCH_HIDDEN_DIRECTORIES,
@@ -225,22 +225,12 @@ import {
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
} from "./worktree-session.js";
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
import {
WorktreeRequestError,
toWorktreeRequestError,
toWorktreeWireError,
} from "./worktree-errors.js";
import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import {
createProjectDirectory,
ProjectDirectoryRequestError,
} from "./project-directory-service.js";
import {
type WorktreeConfig,
createWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
} from "../utils/worktree.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
@@ -682,10 +672,11 @@ export class Session {
logger: this.sessionLogger,
});
this.workspaceRecovery = createWorkspaceRecoveryService({
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
getProject: (projectId) => this.projectRegistry.get(projectId),
isDirectory: (path) => this.filesystem.isDirectory(path),
recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace),
unarchiveWorkspace: async (workspace) => {
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
},
@@ -3985,78 +3976,6 @@ export class Session {
};
}
private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void> {
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
const project = await this.projectRegistry.get(workspace.projectId);
if (!project) {
throw new WorktreeRequestError({
code: "unknown",
message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`,
});
}
const projectRootExists = await this.filesystem
.isDirectory(project.rootPath)
.catch(() => false);
if (!projectRootExists) {
throw new WorktreeRequestError({
code: "unknown",
message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`,
});
}
// Archiving through the default path (scope "workspace", worktreePath only)
// resolves repoRoot=null, so deletePaseoWorktree's `git worktree remove`/
// `prune` is skipped and the admin registration survives — pinning the
// branch as "already checked out". Prune here frees any stale registration
// whose working dir is missing (a no-op for live worktrees) so the recreate
// below succeeds regardless of how the worktree was archived.
try {
await runGitCommand(["worktree", "prune"], { cwd: project.rootPath, timeout: 30_000 });
} catch {
// not critical; git will prune lazily
}
const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
});
const previousWorktreePath =
workspace.worktreeRoot ??
(ownership.allowed ? (ownership.worktreePath ?? workspace.cwd) : workspace.cwd);
let result: WorktreeConfig;
try {
result = await createWorktree({
cwd: project.rootPath,
worktreeSlug: basename(previousWorktreePath),
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
});
} catch (error) {
throw toWorktreeRequestError(error);
}
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
sourceWorktreePath: previousWorktreePath,
workspaceCwd: workspace.cwd,
targetWorktreePath: result.worktreePath,
});
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
throw new WorktreeRequestError({
code: "unknown",
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
});
}
await mkdir(recreatedWorkspacePath, { recursive: true });
}
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
await this.workspaceRecovery.restore(workspaceId);
const workspace = await this.workspaceRegistry.get(workspaceId);
@@ -4130,6 +4049,7 @@ export class Session {
kind: workspace.kind,
worktreeRoot: workspace.worktreeRoot,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
mainRepoRoot: workspace.mainRepoRoot,
}));
}
@@ -5251,11 +5171,6 @@ export class Session {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const gitSnapshot = await this.workspaceGitService
.getSnapshot(existing.cwd)
.catch(() => null);
const repoRoot = gitSnapshot?.git?.repoRoot ?? null;
await archiveByScope(
{
paseoHome: this.paseoHome,
@@ -5278,8 +5193,6 @@ export class Session {
},
{
scope: { kind: "workspace", workspaceId: existing.workspaceId },
repoRoot,
paseoWorktreesBaseRoot: this.worktreesRoot,
requestId: request.requestId,
},
);

View File

@@ -39,7 +39,6 @@ import {
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
writePaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
import { WorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
@@ -129,7 +128,6 @@ interface SessionTestAccess {
agentUpdates: AgentUpdatesService;
workspaceUpdatesSubscription: unknown;
interruptAgentIfRunning(agentId: string): unknown;
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
reconcileWorkspaceRecord(workspaceId: string): Promise<{
changed: boolean;
@@ -5393,127 +5391,6 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () =
rmSync(tempDir, { recursive: true, force: true });
});
test("recreateArchivedWorktree restores an archived exact subdirectory", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo();
const sourceSubdirectory = path.join(repoDir, "packages", "app");
mkdirSync(sourceSubdirectory, { recursive: true });
writeFileSync(path.join(sourceSubdirectory, "README.md"), "app\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
const branch = "feature/subdirectory";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
const worktreesRoot = path.join(tempDir, "worktrees");
const paseoHome = path.join(tempDir, "paseo-home");
const created = await createWorktree({
cwd: repoDir,
worktreeSlug: "subdirectory",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
const worktreeRoot = realpathSync(created.worktreePath);
const workspaceCwd = path.join(worktreeRoot, "packages", "app");
rmSync(worktreeRoot, { recursive: true, force: true });
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
const project = createPersistedProjectRecord({
projectId: repoDir,
rootPath: repoDir,
kind: "git",
displayName: "worktree-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
});
session.projectRegistry.get = async () => project;
await session.recreateArchivedWorktree(
createPersistedWorkspaceRecord({
workspaceId: "ws-subdirectory-recreate",
projectId: project.projectId,
cwd: workspaceCwd,
kind: "worktree",
branch,
worktreeRoot,
isPaseoOwnedWorktree: true,
mainRepoRoot: repoDir,
displayName: branch,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
expect(existsSync(worktreeRoot)).toBe(true);
expect(existsSync(workspaceCwd)).toBe(true);
rmSync(tempDir, { recursive: true, force: true });
});
test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
const worktreesRoot = path.join(tempDir, "worktrees");
const paseoHome = path.join(tempDir, "paseo-home");
const created = await createWorktree({
cwd: repoDir,
worktreeSlug: "keep",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
const worktreePath = realpathSync(created.worktreePath);
const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const workspaceId = "ws-missing-root";
const projectId = repoDir;
const missingRoot = path.join(tempDir, "does-not-exist");
projects.set(
projectId,
createPersistedProjectRecord({
projectId,
rootPath: missingRoot,
kind: "git",
displayName: "worktree-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId,
projectId,
cwd: worktreePath,
kind: "worktree",
branch,
displayName: branch,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
});
workspaces.set(workspaceId, workspaceRecord);
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
session.filesystem.isDirectory = async (target: string) =>
existsSync(target) && statSync(target).isDirectory();
await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
WorktreeRequestError,
);
// Guard fires before createWorktree, so archivedAt is untouched.
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
rmSync(tempDir, { recursive: true, force: true });
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();

View File

@@ -1,4 +1,19 @@
import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createWorktree } from "../../../utils/worktree.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
@@ -8,15 +23,23 @@ import {
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
const NOW = "2026-07-11T10:12:30.752Z";
const tempDirectories: string[] = [];
function createProject(): PersistedProjectRecord {
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createProject(overrides: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: "/repo",
rootPath: "/repo",
projectId: "/project",
rootPath: "/project",
kind: "git",
displayName: "repo",
displayName: "project",
createdAt: NOW,
updatedAt: NOW,
...overrides,
});
}
@@ -25,12 +48,15 @@ function createWorkspace(
): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: "wks_15a1b5630ebaab33",
projectId: "/repo",
projectId: "/project",
cwd: "/worktrees/trigger-1525443412986298439",
kind: "worktree",
displayName: "diagnose-repro-tdd",
title: "Codex TDD reproduction",
title: "TDD reproduction",
branch: "diagnose-repro-tdd",
worktreeRoot: "/worktrees/trigger-1525443412986298439",
isPaseoOwnedWorktree: true,
mainRepoRoot: "/repo",
createdAt: NOW,
updatedAt: NOW,
archivedAt: NOW,
@@ -42,55 +68,53 @@ function createHarness(input?: {
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
directories?: string[];
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
paseoHome?: string;
worktreesRoot?: string;
}) {
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
const project = input?.project === undefined ? createProject() : input.project;
const directories = new Set(input?.directories ?? ["/repo"]);
const unarchived: string[] = [];
const recreated: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome: input?.paseoHome ?? "/paseo-home",
worktreesRoot: input?.worktreesRoot ?? "/worktrees",
getWorkspace: async (workspaceId) =>
workspace?.workspaceId === workspaceId ? workspace : null,
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
isDirectory: async (path) => directories.has(path),
recreateWorktree: async (record) => {
recreated.push(record.workspaceId);
await input?.recreate?.(record);
},
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
return { service, recreated, unarchived };
return { service, unarchived };
}
describe("workspace recovery", () => {
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
const { service, recreated, unarchived } = createHarness();
test("describes a missing archived worktree from persisted placement", async () => {
const { service, unarchived } = createHarness();
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
workspaceName: "TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
});
expect(recreated).toEqual([]);
expect(unarchived).toEqual([]);
});
test("describes an archived workspace whose directory remains as unarchivable", async () => {
test("unarchives an archived workspace whose exact directory remains", async () => {
const workspace = createWorkspace({ kind: "directory", branch: null });
const { service } = createHarness({
const { service, unarchived } = createHarness({
workspace,
directories: ["/repo", workspace.cwd],
directories: [workspace.cwd],
});
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
kind: "recoverable",
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "unarchive",
});
expect(unarchived).toEqual([workspace.workspaceId]);
});
test("does not offer recovery for a missing non-worktree directory", async () => {
@@ -105,27 +129,104 @@ describe("workspace recovery", () => {
});
});
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
let attempts = 0;
const { service, recreated, unarchived } = createHarness({
recreate: async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("git branch diagnose-repro-tdd is unavailable");
}
test("uses the persisted source repository instead of the owning project to restore an exact subdirectory", async () => {
const { tempDir, repoDir } = createGitRepository();
const branch = "feature/mixed-project";
const sourceSubdirectory = join(repoDir, "packages", "app");
mkdirSync(sourceSubdirectory, { recursive: true });
writeFileSync(join(sourceSubdirectory, "README.md"), "app\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
const paseoHome = join(tempDir, "paseo-home");
const worktreesRoot = join(tempDir, "worktrees");
const created = await createWorktree({
cwd: repoDir,
worktreeSlug: "mixed-project",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
const worktreeRoot = realpathSync(created.worktreePath);
const workspaceCwd = join(worktreeRoot, "packages", "app");
rmSync(worktreeRoot, { recursive: true, force: true });
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
const projectRoot = join(tempDir, "explicit-non-git-project");
mkdirSync(projectRoot);
const project = createProject({
projectId: "explicit-non-git-project",
rootPath: projectRoot,
kind: "non_git",
});
const workspace = createWorkspace({
workspaceId: "ws-mixed-project-recreate",
projectId: project.projectId,
cwd: workspaceCwd,
branch,
worktreeRoot,
mainRepoRoot: repoDir,
});
const unarchived: string[] = [];
const service = createWorkspaceRecoveryService({
paseoHome,
worktreesRoot,
getWorkspace: async (workspaceId) =>
workspaceId === workspace.workspaceId ? workspace : null,
getProject: async (projectId) => (projectId === project.projectId ? project : null),
isDirectory: async (path) => existsSync(path) && statSync(path).isDirectory(),
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
"git branch diagnose-repro-tdd is unavailable",
);
expect(unarchived).toEqual([]);
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
workspaceId: "wks_15a1b5630ebaab33",
await expect(service.restore(workspace.workspaceId)).resolves.toEqual({
workspaceId: workspace.workspaceId,
action: "restore",
});
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
expect(existsSync(worktreeRoot)).toBe(true);
expect(existsSync(workspaceCwd)).toBe(true);
expect(unarchived).toEqual([workspace.workspaceId]);
});
test("keeps the workspace archived when its persisted source repository is missing", async () => {
const workspace = createWorkspace({ mainRepoRoot: "/missing-source" });
const { service, unarchived } = createHarness({
workspace,
directories: ["/project"],
});
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
kind: "unavailable",
workspaceId: workspace.workspaceId,
reason: "project_directory_missing",
message: "The source repository needed to restore this worktree no longer exists.",
});
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
"The source repository needed to restore this worktree no longer exists.",
);
expect(unarchived).toEqual([]);
});
});
function createGitRepository(): { tempDir: string; repoDir: string } {
const tempDir = mkdtempSync(join(tmpdir(), "paseo-workspace-recovery-"));
tempDirectories.push(tempDir);
const repoDir = join(tempDir, "repo");
mkdirSync(repoDir);
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: repoDir,
stdio: "pipe",
});
writeFileSync(join(repoDir, "README.md"), "initial\n");
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
return { tempDir, repoDir };
}

View File

@@ -1,3 +1,14 @@
import { mkdir } from "node:fs/promises";
import { basename } from "node:path";
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import {
createWorktree,
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
} from "../../../utils/worktree.js";
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
import {
resolveWorkspaceDisplayName,
type PersistedProjectRecord,
@@ -32,14 +43,32 @@ export interface WorkspaceRecoveryService {
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
}
type RecoveryPlan =
| {
kind: "unarchive";
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
workspace: PersistedWorkspaceRecord;
}
| {
kind: "restore";
state: Extract<WorkspaceRecoveryState, { kind: "recoverable" }>;
workspace: PersistedWorkspaceRecord;
sourceRepoRoot: string;
};
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
export function createWorkspaceRecoveryService(deps: {
paseoHome: string;
worktreesRoot?: string;
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
isDirectory: (path: string) => Promise<boolean>;
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}): WorkspaceRecoveryService {
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
async function resolveRecovery(
workspaceId: string,
): Promise<UnavailableRecoveryState | RecoveryPlan> {
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace) {
return {
@@ -69,13 +98,7 @@ export function createWorkspaceRecoveryService(deps: {
}
if (await deps.isDirectory(workspace.cwd)) {
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "unarchive",
branch: workspace.branch,
};
return createRecoveryPlan({ action: "unarchive", workspace });
}
if (workspace.kind !== "worktree") {
@@ -94,42 +117,130 @@ export function createWorkspaceRecoveryService(deps: {
message: "The archived worktree has no branch recorded, so it cannot be restored.",
};
}
if (!(await deps.isDirectory(project.rootPath))) {
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
// lack placement ownership; remove the project-root fallback after 2027-01-17.
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
if (!(await deps.isDirectory(sourceRepoRoot))) {
return {
kind: "unavailable",
workspaceId,
reason: "project_directory_missing",
message: "The project directory needed to restore this worktree no longer exists.",
message: "The source repository needed to restore this worktree no longer exists.",
};
}
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "restore",
branch: workspace.branch,
};
return createRecoveryPlan({ action: "restore", workspace, sourceRepoRoot });
}
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
const resolved = await resolveRecovery(workspaceId);
return resolved.kind === "unavailable" ? resolved : resolved.state;
}
async function restore(
workspaceId: string,
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
const state = await inspect(workspaceId);
if (state.kind === "unavailable") {
throw new Error(state.message);
const resolved = await resolveRecovery(workspaceId);
if (resolved.kind === "unavailable") {
throw new Error(resolved.message);
}
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace?.archivedAt) {
throw new Error("The archived workspace changed before it could be recovered.");
if (resolved.kind === "restore") {
await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot);
}
if (state.action === "restore") {
await deps.recreateWorktree(workspace);
await deps.unarchiveWorkspace(resolved.workspace);
return { workspaceId, action: resolved.kind };
}
async function recreateArchivedWorktree(
workspace: PersistedWorkspaceRecord,
sourceRepoRoot: string,
): Promise<void> {
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
await deps.unarchiveWorkspace(workspace);
return { workspaceId, action: state.action };
try {
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
} catch {
// A stale worktree registration is not guaranteed; creation reports any real conflict.
}
let previousWorktreePath = workspace.worktreeRoot;
if (!previousWorktreePath) {
// COMPAT(worktreeRestoreMissingWorktreeRoot): records created before v0.1.110
// lack durable backing placement; remove filesystem discovery after 2027-01-17.
const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, {
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
});
previousWorktreePath = ownership.allowed
? (ownership.worktreePath ?? workspace.cwd)
: workspace.cwd;
}
let recreatedWorktreePath: string;
try {
const result = await createWorktree({
cwd: sourceRepoRoot,
worktreeSlug: basename(previousWorktreePath),
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
});
recreatedWorktreePath = result.worktreePath;
} catch (error) {
throw toWorktreeRequestError(error);
}
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
sourceWorktreePath: previousWorktreePath,
workspaceCwd: workspace.cwd,
targetWorktreePath: recreatedWorktreePath,
});
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
throw new WorktreeRequestError({
code: "unknown",
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
});
}
await mkdir(recreatedWorkspacePath, { recursive: true });
}
return { inspect, restore };
}
function createRecoveryPlan(
input:
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
| { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string },
): RecoveryPlan {
const state = {
kind: "recoverable" as const,
workspaceId: input.workspace.workspaceId,
workspaceName: resolveWorkspaceDisplayName(input.workspace),
branch: input.workspace.branch,
};
if (input.action === "restore") {
return {
kind: input.action,
state: { ...state, action: input.action },
workspace: input.workspace,
sourceRepoRoot: input.sourceRepoRoot,
};
}
return {
kind: input.action,
state: {
...state,
action: input.action,
},
workspace: input.workspace,
};
}

View File

@@ -1,5 +1,5 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino, { type Logger } from "pino";
@@ -205,7 +205,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "workspace", workspaceId },
repoRoot: repoDir,
requestId: "req-last-ref-workspace",
},
);
@@ -234,7 +233,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "workspace", workspaceId: workspaceA },
repoRoot: repoDir,
requestId: "req-sibling-workspace",
},
);
@@ -277,7 +275,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "workspace", workspaceId: sourceWorkspaceId },
repoRoot: repoDir,
requestId: "req-subdirectory-sibling",
},
);
@@ -320,7 +317,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "workspace", workspaceId: subdirectoryWorkspaceId },
repoRoot: repoDir,
requestId: "req-subdirectory-target",
},
);
@@ -332,6 +328,60 @@ describe("archiveByScope", () => {
expect(existsSync(worktree.worktreePath)).toBe(true);
});
test("workspace scope runs teardown from the exact nested workspace before deleting its worktree", async () => {
const { tempDir, repoDir } = createGitRepo();
const nestedRelative = path.join("packages", "app");
const sourceNested = path.join(repoDir, nestedRelative);
mkdirSync(sourceNested, { recursive: true });
writeFileSync(
path.join(sourceNested, "paseo.json"),
JSON.stringify({
worktree: {
teardown: [
"node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/nested-teardown.log', process.cwd())\"",
],
},
}),
);
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "nested teardown"], {
cwd: repoDir,
stdio: "pipe",
});
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "nested-teardown");
const workspaceCwd = path.join(worktree.worktreePath, nestedRelative);
const workspaceId = "ws-nested-teardown";
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: workspaceCwd,
kind: "worktree",
worktreeRoot: worktree.worktreePath,
isPaseoOwnedWorktree: true,
mainRepoRoot: repoDir,
},
],
}),
{
scope: { kind: "workspace", workspaceId },
requestId: "req-nested-teardown",
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [workspaceId],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
expect(readFileSync(path.join(repoDir, "nested-teardown.log"), "utf8")).toBe(workspaceCwd);
});
test("worktree scope archives root and subdirectory workspaces before removing the backing worktree", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
@@ -371,7 +421,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-worktree-scope",
},
);
@@ -396,7 +445,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "workspace", workspaceId },
repoRoot: null,
requestId: "req-local-checkout",
},
);
@@ -432,7 +480,6 @@ describe("archiveByScope", () => {
const result = await archiveByScope(deps, {
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-partial-failure",
});
@@ -457,7 +504,6 @@ describe("archiveByScope", () => {
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId: "ws-does-not-exist" },
repoRoot: null,
requestId: "req-unknown-workspace",
});
@@ -482,7 +528,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-zero-records",
},
);
@@ -562,7 +607,6 @@ describe("archiveByScope", () => {
await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
repoRoot: repoDir,
requestId: "req-lifecycle",
});
@@ -616,7 +660,6 @@ describe("archiveByScope", () => {
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
repoRoot: repoDir,
requestId: "req-snapshot-scope",
});
@@ -650,7 +693,6 @@ describe("archiveByScope", () => {
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
repoRoot: repoDir,
requestId: "req-worktree-scope-n3",
},
);

View File

@@ -16,18 +16,14 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
export interface ActiveWorkspaceRef {
workspaceId: string;
cwd: string;
kind?: "local_checkout" | "worktree" | "directory";
worktreeRoot?: string | null;
isPaseoOwnedWorktree?: boolean;
}
export type ActiveWorkspaceRef = Pick<
PersistedWorkspaceRecord,
"workspaceId" | "cwd" | "kind" | "worktreeRoot" | "isPaseoOwnedWorktree" | "mainRepoRoot"
>;
export interface ArchiveDependencies {
paseoHome?: string;
// Base directory that may hold worktrees across repositories. Used as a fallback
// when the request does not supply a per-repo root.
// Base directory that may hold worktrees across repositories.
paseoWorktreesBaseRoot?: string;
github: ForgeService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
@@ -68,15 +64,22 @@ export interface ArchiveResult {
export interface ArchiveByScopeRequest {
scope: ArchiveScope;
repoRoot: string | null;
// Per-repository worktree root, used to remove the actual directory.
repoWorktreesRoot?: string;
// Base directory that may hold worktrees across repositories; falls back to the
// dependency's base root for ownership checks and path resolution.
paseoWorktreesBaseRoot?: string;
requestId: string;
}
interface BackingDirectory {
path: string;
isPaseoOwnedWorktree: boolean;
mainRepoRoot: string | null;
paseoWorktreesRoot: string | null;
}
interface ArchiveTarget {
backing: BackingDirectory | null;
teardownCwd: string | null;
workspaceIds: string[];
}
export async function resolveWorkspaceIdAtPath(
dependencies: Pick<ArchiveDependencies, "findWorkspaceIdForCwd" | "listActiveWorkspaces">,
targetPath: string,
@@ -91,18 +94,15 @@ export async function resolveWorkspaceIdAtPath(
return dependencies.findWorkspaceIdForCwd(targetPath);
}
// THE single archive entry. Resolves the in-scope record set, tears each down
// Resolves the in-scope record set, tears each down
// (agents + terminals + record), then removes the backing directory iff it is
// Paseo-owned AND no active workspace still references it.
export async function archiveByScope(
dependencies: ArchiveDependencies,
request: ArchiveByScopeRequest,
): Promise<ArchiveResult> {
const { targetDir, targetWorkspaceIds } = await resolveArchiveTargets(
dependencies,
request.scope,
request.paseoWorktreesBaseRoot,
);
const target = await resolveArchiveTarget(dependencies, request.scope);
const targetWorkspaceIds = target.workspaceIds;
if (targetWorkspaceIds.length > 0) {
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
@@ -121,25 +121,25 @@ export async function archiveByScope(
request.requestId,
);
if (request.repoRoot) {
if (target.backing?.mainRepoRoot) {
try {
await dependencies.workspaceGitService.getSnapshot(request.repoRoot, {
await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, {
force: true,
reason: "archive-worktree",
});
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, cwd: request.repoRoot, requestId: request.requestId },
{ err: error, cwd: target.backing.mainRepoRoot, requestId: request.requestId },
"Failed to force-refresh workspace git snapshot after archiving",
);
}
}
if (targetDir !== null) {
if (target.backing !== null) {
removedDirectory = await maybeRemoveDirectory(
dependencies,
request,
targetDir,
target,
archivedWorkspaceIds,
);
}
@@ -157,11 +157,10 @@ export async function archiveByScope(
}
}
async function resolveArchiveTargets(
async function resolveArchiveTarget(
dependencies: ArchiveDependencies,
scope: ArchiveScope,
paseoWorktreesBaseRoot?: string,
): Promise<{ targetDir: string | null; targetWorkspaceIds: string[] }> {
): Promise<ArchiveTarget> {
const activeWorkspaces = await dependencies.listActiveWorkspaces();
if (scope.kind === "workspace") {
@@ -172,66 +171,95 @@ async function resolveArchiveTargets(
{ workspaceId },
"Workspace not found for archive-by-scope; skipping",
);
return { targetDir: null, targetWorkspaceIds: [] };
return { backing: null, teardownCwd: null, workspaceIds: [] };
}
return {
targetDir: await resolveWorkspaceBackingDirectory(
record,
dependencies,
paseoWorktreesBaseRoot,
),
targetWorkspaceIds: [workspaceId],
backing: await resolveWorkspaceBackingDirectory(record, dependencies),
teardownCwd: record.cwd,
workspaceIds: [workspaceId],
};
}
let targetPath = scope.targetPath;
targetPath = await resolveBackingWorktreeDirectory(
targetPath,
dependencies,
paseoWorktreesBaseRoot,
);
const targetDir = resolve(targetPath);
const matchesTargetDir = createRealpathAwarePathMatcher(targetDir);
const targetWorkspaceIds = (
const backing = await resolveBackingDirectory(scope.targetPath, dependencies);
const matchesBackingDirectory = createRealpathAwarePathMatcher(backing.path);
const targetWorkspaces = (
await Promise.all(
activeWorkspaces.map(async (workspace) => {
const backingDirectory = await resolveWorkspaceBackingDirectory(
workspace,
dependencies,
paseoWorktreesBaseRoot,
);
return matchesTargetDir(backingDirectory) ? workspace.workspaceId : null;
const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies);
return matchesBackingDirectory(backingDirectory.path) ? workspace : null;
}),
)
).filter((workspaceId): workspaceId is string => workspaceId !== null);
return { targetDir, targetWorkspaceIds };
).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null);
const exactTarget = createRealpathAwarePathMatcher(scope.targetPath);
const teardownWorkspace = targetWorkspaces.find((workspace) => exactTarget(workspace.cwd));
const persistedMainRepoRoot = targetWorkspaces.find(
(workspace) => workspace.mainRepoRoot,
)?.mainRepoRoot;
return {
backing: {
...backing,
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
},
teardownCwd: teardownWorkspace?.cwd ?? scope.targetPath,
workspaceIds: targetWorkspaces.map((workspace) => workspace.workspaceId),
};
}
async function resolveWorkspaceBackingDirectory(
workspace: ActiveWorkspaceRef,
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
paseoWorktreesBaseRoot?: string,
): Promise<string> {
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot) {
return resolve(workspace.worktreeRoot);
): Promise<BackingDirectory> {
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) {
return {
path: resolve(workspace.worktreeRoot),
isPaseoOwnedWorktree: true,
mainRepoRoot: workspace.mainRepoRoot,
paseoWorktreesRoot: null,
};
}
return resolveBackingWorktreeDirectory(workspace.cwd, dependencies, paseoWorktreesBaseRoot);
if (workspace.kind !== "worktree") {
return {
path: resolve(workspace.cwd),
isPaseoOwnedWorktree: false,
mainRepoRoot: workspace.mainRepoRoot ?? null,
paseoWorktreesRoot: null,
};
}
// COMPAT(archiveMissingWorkspacePlacement): worktree records created before v0.1.110
// lack durable backing ownership; remove filesystem discovery after 2027-01-17.
const backing = await resolveBackingDirectory(
workspace.worktreeRoot ?? workspace.cwd,
dependencies,
);
return { ...backing, mainRepoRoot: workspace.mainRepoRoot ?? backing.mainRepoRoot };
}
async function resolveBackingWorktreeDirectory(
async function resolveBackingDirectory(
cwd: string,
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
paseoWorktreesBaseRoot?: string,
): Promise<string> {
): Promise<BackingDirectory> {
const options = {
paseoHome: dependencies.paseoHome,
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
};
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(cwd, options);
if (resolvedWorktree) return resolvedWorktree.worktreePath;
if (resolvedWorktree) {
return {
path: resolve(resolvedWorktree.worktreePath),
isPaseoOwnedWorktree: true,
mainRepoRoot: resolvedWorktree.repoRoot,
paseoWorktreesRoot: resolvedWorktree.worktreeRoot,
};
}
const ownership = await isPaseoOwnedWorktreeCwd(cwd, options);
return ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolve(cwd);
return {
path: resolve(ownership.allowed && ownership.worktreePath ? ownership.worktreePath : cwd),
isPaseoOwnedWorktree: ownership.allowed,
mainRepoRoot: ownership.repoRoot ?? null,
paseoWorktreesRoot: ownership.worktreeRoot ?? null,
};
}
async function archiveTargetRecords(
@@ -269,15 +297,12 @@ async function archiveTargetRecords(
async function maybeRemoveDirectory(
dependencies: ArchiveDependencies,
request: Omit<ArchiveByScopeRequest, "scope">,
targetDir: string,
request: Pick<ArchiveByScopeRequest, "requestId">,
target: ArchiveTarget,
archivedWorkspaceIds: string[],
): Promise<boolean> {
const ownership = await isPaseoOwnedWorktreeCwd(targetDir, {
paseoHome: dependencies.paseoHome,
worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
});
if (!ownership.allowed) {
const backing = target.backing;
if (!backing?.isPaseoOwnedWorktree) {
return false;
}
@@ -285,10 +310,9 @@ async function maybeRemoveDirectory(
if (
!(await isDirectoryUnreferenced(
remainingActive,
targetDir,
backing.path,
new Set(archivedWorkspaceIds),
dependencies,
request,
))
) {
return false;
@@ -296,18 +320,19 @@ async function maybeRemoveDirectory(
try {
await deletePaseoWorktree({
cwd: request.repoRoot,
worktreePath: targetDir,
worktreesRoot: request.repoWorktreesRoot ?? ownership.worktreeRoot,
cwd: backing.mainRepoRoot,
worktreePath: backing.path,
teardownCwd: target.teardownCwd ?? backing.path,
worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
});
dependencies.github.invalidate({ cwd: targetDir });
dependencies.github.invalidate({ cwd: backing.path });
return true;
} catch (error) {
if (error instanceof WorktreeTeardownError) {
dependencies.sessionLogger?.warn(
{ err: error, targetPath: targetDir, requestId: request.requestId },
{ err: error, targetPath: backing.path, requestId: request.requestId },
"Worktree disk removal failed during archive; workspace already archived",
);
return false;
@@ -376,7 +401,7 @@ export async function archiveWorkspaceContents(
return archivedAgents;
}
// EXACTLY one last-reference predicate in the module. True when, after archiving
// True when, after archiving
// the in-scope records, no active workspace still points at targetDir. Derived
// from records each call — no stored counter.
async function isDirectoryUnreferenced(
@@ -384,18 +409,13 @@ async function isDirectoryUnreferenced(
targetDir: string,
archivedWorkspaceIds: ReadonlySet<string>,
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
request: Pick<ArchiveByScopeRequest, "paseoWorktreesBaseRoot">,
): Promise<boolean> {
const target = resolve(targetDir);
const matchesTarget = createRealpathAwarePathMatcher(target);
for (const workspace of activeWorkspaces) {
if (archivedWorkspaceIds.has(workspace.workspaceId)) continue;
const backingDirectory = await resolveWorkspaceBackingDirectory(
workspace,
dependencies,
request.paseoWorktreesBaseRoot,
);
if (matchesTarget(backingDirectory)) return false;
const backingDirectory = await resolveWorkspaceBackingDirectory(workspace, dependencies);
if (matchesTarget(backingDirectory.path)) return false;
}
return true;
}

View File

@@ -951,6 +951,102 @@ describe("WorkspaceReconciliationService", () => {
expect(workspaces.get(workspace.workspaceId)).toEqual(workspace);
});
test("archives non-directory workspaces without blocking sibling reconciliation", async () => {
const projectRoot = mkdtempSync(path.join(tmpdir(), "reconcile-file-workspace-"));
const replacedWorkspace = path.join(projectRoot, "replaced-workspace");
const siblingWorkspace = path.join(projectRoot, "sibling-workspace");
writeFileSync(replacedWorkspace, "not a directory\n");
mkdirSync(siblingWorkspace);
tempDirs.push(projectRoot);
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: projectRoot,
kind: "non_git",
displayName: "project",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"replaced",
createPersistedWorkspaceRecord({
workspaceId: "replaced",
projectId: "p1",
cwd: replacedWorkspace,
kind: "directory",
displayName: "replaced-workspace",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"sibling",
createPersistedWorkspaceRecord({
workspaceId: "sibling",
projectId: "p1",
cwd: siblingWorkspace,
kind: "directory",
displayName: "sibling-workspace",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger: createTestLogger(),
workspaceGitService: {
getCheckout: async (cwd) => {
if (cwd === replacedWorkspace) {
throw new Error("Git cannot use a regular file as cwd");
}
return createCheckout(cwd, {
isGit: true,
currentBranch: cwd === siblingWorkspace ? "feature/sibling" : "main",
worktreeRoot: cwd,
});
},
},
});
const result = await service.runOnce();
expect(result.changesApplied).toEqual([
{
kind: "workspace_archived",
workspaceId: "replaced",
directory: replacedWorkspace,
reason: "directory_missing",
},
{
kind: "project_updated",
projectId: "p1",
directory: projectRoot,
fields: { kind: "git" },
},
{
kind: "workspace_updated",
workspaceId: "sibling",
directory: siblingWorkspace,
fields: {
kind: "local_checkout",
branch: "feature/sibling",
worktreeRoot: siblingWorkspace,
},
},
]);
expect(workspaces.get("replaced")?.archivedAt).toEqual(expect.any(String));
expect(workspaces.get("sibling")).toMatchObject({
kind: "local_checkout",
branch: "feature/sibling",
});
});
test("updates workspace branch metadata without clobbering the workspace name", async () => {
const dir = createTempGitRepo("reconcile-branch-");
tempDirs.push(dir);

View File

@@ -1,4 +1,4 @@
import { existsSync, watch as watchPath } from "node:fs";
import { statSync, watch as watchPath } from "node:fs";
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
import type pino from "pino";
import type {
@@ -107,6 +107,8 @@ interface CachedCheckoutRead {
checkout: Promise<ProjectCheckoutLitePayload>;
}
type DirectoryState = "directory" | "missing" | "unreadable";
export class WorkspaceReconciliationService {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
@@ -189,13 +191,15 @@ export class WorkspaceReconciliationService {
]);
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
for (const workspace of workspaces) {
if (workspace.archivedAt) continue;
if (workspace.archivedAt || this.inspectDirectory(workspace.cwd) !== "directory") continue;
const siblings = workspacesByProject.get(workspace.projectId) ?? [];
siblings.push(workspace);
workspacesByProject.set(workspace.projectId, siblings);
}
await this.reconcileGitMetadataForProjects(
projects.filter((project) => !project.archivedAt && existsSync(project.rootPath)),
projects.filter(
(project) => !project.archivedAt && this.inspectDirectory(project.rootPath) === "directory",
),
workspacesByProject,
changes,
);
@@ -212,16 +216,23 @@ export class WorkspaceReconciliationService {
const activeProjects = allProjects.filter((p) => !p.archivedAt);
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
const workspaceDirectoryStates = activeWorkspaces.map((workspace) => ({
workspace,
state: this.inspectDirectory(workspace.cwd),
}));
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
for (const workspace of activeWorkspaces) {
for (const { workspace, state } of workspaceDirectoryStates) {
if (state !== "directory") continue;
const list = workspacesByProject.get(workspace.projectId) ?? [];
list.push(workspace);
workspacesByProject.set(workspace.projectId, list);
}
// 1. Archive workspaces whose directories no longer exist
const missingWorkspaces = activeWorkspaces.filter((workspace) => !existsSync(workspace.cwd));
const missingWorkspaces = workspaceDirectoryStates
.filter(({ state }) => state === "missing")
.map(({ workspace }) => workspace);
await Promise.all(
missingWorkspaces.map(async (workspace) => {
const timestamp = new Date().toISOString();
@@ -246,7 +257,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) => existsSync(project.rootPath)),
activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"),
workspacesByProject,
changes,
);
@@ -313,9 +324,8 @@ export class WorkspaceReconciliationService {
private async reconcileProject(input: ProjectReconciliationInput): Promise<void> {
const { project, siblings, currentGit, readCheckout, changes } = input;
const existingSiblings = siblings.filter((workspace) => existsSync(workspace.cwd));
const workspaceCheckouts = await Promise.all(
existingSiblings.map(async (workspace) => ({
siblings.map(async (workspace) => ({
workspace,
checkout: await readCheckout(workspace.cwd),
})),
@@ -476,4 +486,22 @@ export class WorkspaceReconciliationService {
}
return this.workspaceGitService.getCheckout(cwd);
}
private inspectDirectory(targetPath: string): DirectoryState {
try {
return statSync(targetPath).isDirectory() ? "directory" : "missing";
} catch (error) {
if (isMissingPathError(error)) return "missing";
this.logger.warn(
{ err: error, targetPath },
"Skipped workspace reconciliation after directory inspection failed",
);
return "unreadable";
}
}
}
function isMissingPathError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) return false;
return error.code === "ENOENT" || error.code === "ENOTDIR";
}

View File

@@ -1,10 +1,9 @@
import { describe, expect, test } from "vitest";
import { basename, isAbsolute } from "node:path";
import { isAbsolute } from "node:path";
import {
checkoutFromPersistedWorkspacePlacement,
deriveWorkspaceKind,
detectStaleWorkspaces,
generateWorkspaceId,
generateProjectId,
initialWorkspacePlacement,
@@ -12,68 +11,6 @@ import {
} from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
function createWorkspaceRecord(
cwd: string,
workspaceId: string,
overrides?: { createdAt?: string; archivedAt?: string },
) {
return createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd,
kind: "directory",
displayName: basename(cwd) || cwd,
createdAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
updatedAt: overrides?.createdAt ?? "2026-03-01T00:00:00.000Z",
archivedAt: overrides?.archivedAt ?? null,
});
}
describe("detectStaleWorkspaces", () => {
test("returns workspace ids whose directories no longer exist", async () => {
const checkedDirectories: string[] = [];
const existingDirectories = new Set(["/tmp/existing"]);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/existing", "ws-existing"),
createWorkspaceRecord("/tmp/missing", "ws-missing"),
],
checkDirectoryExists: async (cwd) => {
checkedDirectories.push(cwd);
return existingDirectories.has(cwd);
},
});
expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]);
expect(checkedDirectories).toEqual(["/tmp/existing", "/tmp/missing"]);
});
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/repo", "ws-repo"),
createWorkspaceRecord("/tmp/other", "ws-other"),
],
checkDirectoryExists: async () => true,
});
expect(Array.from(staleWorkspaceIds)).toEqual([]);
});
test("keeps workspaces with no agents when directory exists", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/active", "ws-active"),
createWorkspaceRecord("/tmp/no-agents", "ws-no-agents"),
],
checkDirectoryExists: async () => true,
});
expect(Array.from(staleWorkspaceIds)).toEqual([]);
});
});
describe("opaque registry ids", () => {
test("generates opaque project ids", () => {
expect(generateProjectId()).toMatch(/^prj_[0-9a-f]{16}$/);

View File

@@ -9,11 +9,6 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
export interface DetectStaleWorkspacesInput {
activeWorkspaces: PersistedWorkspaceRecord[];
checkDirectoryExists: (cwd: string) => Promise<boolean>;
}
export function generateWorkspaceId(): string {
return `wks_${randomBytes(8).toString("hex")}`;
}
@@ -231,23 +226,3 @@ export function checkoutLiteFromGitSnapshot(
mainRepoRoot: git.mainRepoRoot,
};
}
export async function detectStaleWorkspaces(
input: DetectStaleWorkspacesInput,
): Promise<Set<string>> {
const staleWorkspaceIds = new Set<string>();
const existenceChecks = await Promise.all(
input.activeWorkspaces.map(async (workspace) => ({
workspace,
exists: await input.checkDirectoryExists(workspace.cwd),
})),
);
for (const { workspace, exists } of existenceChecks) {
if (!exists) {
staleWorkspaceIds.add(workspace.workspaceId);
}
}
return staleWorkspaceIds;
}

View File

@@ -122,9 +122,9 @@ export async function archiveCommand(
dependencies: ArchiveCommandDependencies,
input: ArchiveCommandInput,
): Promise<ArchiveCommandResult> {
const resolvedTarget = await resolveArchiveTarget(dependencies, input);
const targetPath = await resolveArchiveTarget(dependencies, input);
const scope = input.scope ?? "workspace";
const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, {
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.paseoWorktreesBaseRoot,
});
@@ -140,10 +140,7 @@ export async function archiveCommand(
}
const result = await archiveByScope(dependencies, {
scope: { kind: "worktree", targetPath: resolvedTarget.targetPath },
repoRoot: ownership.repoRoot ?? resolvedTarget.repoRoot ?? null,
repoWorktreesRoot: ownership.worktreeRoot,
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
scope: { kind: "worktree", targetPath },
requestId: input.requestId,
});
@@ -154,11 +151,11 @@ export async function archiveCommand(
}
const workspaceId =
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, resolvedTarget.targetPath));
input.workspaceId ?? (await resolveWorkspaceIdAtPath(dependencies, targetPath));
if (!workspaceId) {
dependencies.sessionLogger?.warn(
{ targetPath: resolvedTarget.targetPath },
{ targetPath },
"Could not resolve workspace for archive; skipping",
);
return {
@@ -169,9 +166,6 @@ export async function archiveCommand(
const result = await archiveByScope(dependencies, {
scope: { kind: "workspace", workspaceId },
repoRoot: resolvedTarget.repoRoot ?? ownership.repoRoot ?? null,
repoWorktreesRoot: ownership.worktreeRoot,
paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
requestId: input.requestId,
});
@@ -181,28 +175,20 @@ export async function archiveCommand(
};
}
interface ResolvedArchiveTarget {
targetPath: string;
repoRoot: string | null;
}
async function resolveArchiveTarget(
dependencies: ArchiveCommandDependencies,
input: ArchiveCommandInput,
): Promise<ResolvedArchiveTarget> {
): Promise<string> {
const repoRoot = input.repoRoot ?? null;
if (input.worktreePath) {
return { targetPath: input.worktreePath, repoRoot };
return input.worktreePath;
}
if (input.worktreeSlug) {
if (!repoRoot) {
throw new Error("repoRoot is required when worktreeSlug is supplied");
}
return {
targetPath: await resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug),
repoRoot,
};
return resolveWorktreeSlugPath(dependencies, repoRoot, input.worktreeSlug);
}
if (repoRoot && input.branchName) {
@@ -211,7 +197,7 @@ async function resolveArchiveTarget(
if (!match) {
throw new Error(`Paseo worktree not found for branch ${input.branchName}`);
}
return { targetPath: match.path, repoRoot };
return match.path;
}
throw new Error("worktreePath, worktreeSlug, or repoRoot+branchName is required");

View File

@@ -724,11 +724,15 @@ export async function resolveWorktreeRuntimeEnv(options: {
export async function runWorktreeTeardownCommands(options: {
worktreePath: string;
teardownCwd?: string;
branchName?: string;
repoRootPath?: string;
}): Promise<WorktreeTeardownCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const teardownCommands = getWorktreeTeardownCommands(options.worktreePath);
const teardownCwd = options.teardownCwd ?? options.worktreePath;
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
}
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
if (teardownCommands.length === 0) {
return [];
}
@@ -756,7 +760,7 @@ export async function runWorktreeTeardownCommands(options: {
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, {
cwd: options.worktreePath,
cwd: teardownCwd,
env: teardownEnv,
});
results.push(result);
@@ -1106,6 +1110,7 @@ export async function resolvePaseoWorktreeRootForCwd(
export async function deletePaseoWorktree({
cwd,
worktreePath,
teardownCwd,
worktreeSlug,
worktreesRoot,
paseoHome,
@@ -1113,6 +1118,7 @@ export async function deletePaseoWorktree({
}: {
cwd: string | null;
worktreePath?: string;
teardownCwd?: string;
worktreeSlug?: string;
worktreesRoot?: string;
paseoHome?: string;
@@ -1152,6 +1158,7 @@ export async function deletePaseoWorktree({
if (await pathExists(resolvedWorktree)) {
await runWorktreeTeardownCommands({
worktreePath: resolvedWorktree,
teardownCwd,
});
}