From cf4d702a8e86bfa54a71f8c090ec2d52fffb1ed2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 21:44:48 +0200 Subject: [PATCH] fix(workspaces): release archived runtime backing --- .../browser/agent-consecutive-turns.spec.ts | 9 +- .../app/e2e/browser/worktree-restore.spec.ts | 16 ++- .../scripts/dev-build-contract.test.mjs | 7 +- .../agent/create-agent-lifecycle-dispatch.ts | 3 + .../src/server/agent/tools/paseo-tools.ts | 3 + .../archive-if-safe.test.ts | 1 + .../auto-archive-on-merge/archive-if-safe.ts | 5 +- packages/server/src/server/bootstrap.ts | 7 ++ ...ate-agent-worktree-autoarchive.e2e.test.ts | 20 ++- packages/server/src/server/session.ts | 4 + .../workspace-recovery-service.test.ts | 38 ++++++ .../workspace-recovery-service.ts | 22 +++- .../server/workspace-archive-service.test.ts | 116 ++++++++++++++++-- .../src/server/workspace-archive-service.ts | 75 ++++++++++- .../internal/worktree-runtime.ts | 19 +++ .../workspace-runtime.posix.test.ts | 30 ++++- .../server/src/server/worktree/commands.ts | 2 + 17 files changed, 346 insertions(+), 31 deletions(-) diff --git a/packages/app/e2e/browser/agent-consecutive-turns.spec.ts b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts index 8032da7939..872f286c23 100644 --- a/packages/app/e2e/browser/agent-consecutive-turns.spec.ts +++ b/packages/app/e2e/browser/agent-consecutive-turns.spec.ts @@ -809,6 +809,7 @@ test("keeps the first prompt of a new agent in place through authoritative hydra ); }); const gate = await installDaemonWebSocketGate(page); + gate.setAgentStreamItemSuppressed("assistant_message", true); await gotoWorkspace(page, workspace.workspaceId); await clickNewChat(page); await expectComposerVisible(page); @@ -816,14 +817,20 @@ test("keeps the first prompt of a new agent in place through authoritative hydra const prompt = "Delay synthetic user message by 300ms."; await attachImageFromMenu(page, FIRST_PROMPT_IMAGE); await expectAttachmentPill(page, "composer-image-attachment-pill"); + gate.holdNextServerMessage("fetch_agent_timeline_response"); await recordTurnFrames(page, prompt); await submitMessage(page, prompt); const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first(); await expect(submittedRow).toBeVisible(); - await gate.waitForServerMessage("fetch_agent_timeline_response"); + await gate.waitForHeldServerMessage("fetch_agent_timeline_response"); + gate.truncateHeldTimelineAfterLast("user_message"); + gate.setServerMessageSuppressed("fetch_agent_timeline_response", true); + gate.releaseHeldServerMessage("fetch_agent_timeline_response"); await recordPaintsFor(page, 80); expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page)); + gate.setServerMessageSuppressed("fetch_agent_timeline_response", false); + gate.setAgentStreamItemSuppressed("assistant_message", false); } finally { await workspace.cleanup(); } diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index d8bac26fe4..8ede2e8c2c 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -59,15 +59,16 @@ test.describe("Worktree restore", () => { }); createdProjectIds.add(worktree.projectKey); createdWorktreeDirectories.add(worktree.workspaceDirectory); - const agents = await Promise.all( - Array.from({ length: options.agentCount ?? 1 }, () => - createIdleAgent(client, { + const agents = []; + for (let index = 0; index < (options.agentCount ?? 1); index += 1) { + agents.push( + await createIdleAgent(client, { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId, title: `${prefix}-${randomUUID().slice(0, 8)}`, }), - ), - ); + ); + } const agent = agents[0]; if (!agent) { throw new Error("Expected at least one archived-worktree agent"); @@ -369,10 +370,7 @@ test.describe("Worktree restore", () => { timeout: 30_000, }); await expect( - page.getByText( - "The archived workspace directory no longer exists and cannot be recreated.", - { exact: true }, - ), + page.getByText("The archived workspace runtime is missing.", { exact: true }), ).toBeVisible(); await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0); } finally { diff --git a/packages/desktop/scripts/dev-build-contract.test.mjs b/packages/desktop/scripts/dev-build-contract.test.mjs index f05aaaa0a7..0c43028bce 100644 --- a/packages/desktop/scripts/dev-build-contract.test.mjs +++ b/packages/desktop/scripts/dev-build-contract.test.mjs @@ -77,7 +77,12 @@ describe("desktop dev build contract", () => { (await stat(sourceExport)).mtimeMs, ); } finally { - await rm(isolatedRoot, { recursive: true, force: true }); + await rm(isolatedRoot, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); } }, 70_000, diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 29716ea029..2279078e5f 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -4,6 +4,7 @@ import type pino from "pino"; import type { ForgeService } from "../../services/forge-service.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js"; +import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -28,6 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies { archiveAgentForClose: (agentId: string) => Promise; findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; + listWorkspaceRecords: () => Promise; archiveWorkspaceRecord: ( workspaceId: string, options?: { releaseBacking?: boolean }, @@ -220,6 +222,7 @@ export class CreateAgentLifecycleDispatch { agentStorage: this.dependencies.agentStorage, findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, listActiveWorkspaces: this.dependencies.listActiveWorkspaces, + listWorkspaceRecords: this.dependencies.listWorkspaceRecords, archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index fcc314ac11..cebb4301da 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -108,6 +108,7 @@ export interface PaseoToolHostDependencies { >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; + listWorkspaceRecords?: ArchiveDependencies["listWorkspaceRecords"]; archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; workspaceRegistry?: Pick; @@ -1384,6 +1385,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { requestId: "mcp:archive_workspace", scope: { kind: "workspace", workspaceId: workspace.workspaceId }, + releaseBacking: true, }, ); return { @@ -3200,6 +3202,7 @@ function archiveWorktreeDependencies( agentStorage: context.agentStorage, findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, + listWorkspaceRecords: options.listWorkspaceRecords, archiveWorkspaceRecord: options.archiveWorkspaceRecord, emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index fe41b474d7..2f3c73b469 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -505,6 +505,7 @@ describe("archiveIfSafe", () => { { scope: { kind: "workspace", workspaceId: "ws-auto-archive" }, requestId: "auto-archive-on-merge", + releaseBacking: true, }, ); expect(harness.log.info).toHaveBeenCalledWith( diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 02c44bb9ba..6a08c562da 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -16,7 +16,7 @@ import type { import type { ForgeService } from "../../services/forge-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; -import type { WorkspaceArchiveContext } from "../workspace-registry.js"; +import type { PersistedWorkspaceRecord, WorkspaceArchiveContext } from "../workspace-registry.js"; export interface AutoArchiveArchiveOptions { paseoHome: string; @@ -29,6 +29,7 @@ export interface AutoArchiveArchiveOptions { terminalManager: TerminalManager; findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; + listWorkspaceRecords: () => Promise; getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise; archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; @@ -129,6 +130,7 @@ export async function archiveIfSafe(input: { agentStorage: options.agentStorage, findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, + listWorkspaceRecords: options.listWorkspaceRecords, archiveWorkspaceRecord: (workspaceIdToArchive) => options.archiveWorkspaceRecord(workspaceIdToArchive, { autoArchivedChangeRequestUrl: pullRequest.url, @@ -149,6 +151,7 @@ export async function archiveIfSafe(input: { { scope: { kind: "workspace", workspaceId }, requestId: "auto-archive-on-merge", + releaseBacking: true, }, ); log.info({ cwd }, "Auto-archived worktree after PR merge"); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index a582f07a37..16fa04371d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1214,6 +1214,7 @@ export async function createPaseoDaemon( runtimeId: workspace.runtime?.runtimeId ?? null, })); }; + const listWorkspaceRecordsExternal = () => workspaceRegistry.list(); const markWorkspaceArchivingExternal = (workspaceIds: Iterable, archivingAt: string) => { const workspaceIdList = Array.from(workspaceIds); for (const session of wsServer?.listTrustedSessions() ?? []) { @@ -1278,6 +1279,7 @@ export async function createPaseoDaemon( logger, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, getAutoArchivedChangeRequestUrl: async (workspaceId) => (await workspaceRegistry.get(workspaceId))?.autoArchivedChangeRequestUrl ?? null, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, @@ -1364,6 +1366,7 @@ export async function createPaseoDaemon( agentStorage, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet), archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, @@ -1388,6 +1391,7 @@ export async function createPaseoDaemon( archiveAgentCommand({ agentManager, agentStorage, logger }, agentId), findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, destroyWorkspace: (workspaceId) => workspaceRuntime.destroy(workspaceId), emit: emitExternalSessionMessage, @@ -1464,6 +1468,7 @@ export async function createPaseoDaemon( agentStorage, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, getWorkspace: (workspaceIdToGet) => workspaceRegistry.get(workspaceIdToGet), archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, @@ -1483,6 +1488,7 @@ export async function createPaseoDaemon( { scope: { kind: "workspace", workspaceId }, requestId: "schedule-run-finish", + releaseBacking: true, }, ); }; @@ -1530,6 +1536,7 @@ export async function createPaseoDaemon( workspaceGitService, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + listWorkspaceRecords: listWorkspaceRecordsExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, workspaceRegistry, diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 753eb10bd9..d920293d45 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -89,6 +89,18 @@ async function expectWorktreePresentInList(repoDir: string, worktreePath: string .toBe(true); } +async function expectWorktreeAbsentFromList(repoDir: string, worktreePath: string): Promise { + await expect + .poll( + async () => { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); + return listed.worktrees.map((worktree) => worktree.worktreePath).includes(worktreePath); + }, + { timeout: 10_000, interval: 100 }, + ) + .toBe(false); +} + async function expectWorktreeListEmpty(repoDir: string): Promise { const listed = await ctx.client.getPaseoWorktreeList({ cwd: repoDir }); expect(listed.worktrees).toEqual([]); @@ -336,16 +348,16 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); -test("archiving a created worktree preserves it for restore", async () => { +test("archiving a created worktree releases its backing for restore", async () => { const created = await createAgentInBranchOffWorktree(); await ctx.client.waitForFinish(created.agentId, 10000); await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); await expectAgentAbsentFromActiveList(created.agentId); - await expectWorktreePresentInList(created.repoDir, created.worktreePath); - expect(existsSync(created.worktreePath)).toBe(true); -}); + await expectWorktreeAbsentFromList(created.repoDir, created.worktreePath); + expect(existsSync(created.worktreePath)).toBe(false); +}, 30_000); test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { const created = await createAgentInBranchOffWorktree({ autoArchive: true, startIdle: true }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 62b78b34bc..668d66f35a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -998,6 +998,7 @@ export class Session { archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), + listWorkspaceRecords: () => this.workspaceRegistry.list(), archiveWorkspaceRecord: (workspaceId, archiveOptions) => this.archiveWorkspaceRecord(workspaceId, archiveOptions), destroyWorkspace: async (workspaceId) => { @@ -4194,6 +4195,7 @@ export class Session { agentStorage: this.agentStorage, findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), + listWorkspaceRecords: () => this.workspaceRegistry.list(), archiveWorkspaceRecord: (workspaceId, options) => this.archiveWorkspaceRecord(workspaceId, options), emit: (message) => this.emit(message), @@ -6473,6 +6475,7 @@ export class Session { findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), + listWorkspaceRecords: () => this.workspaceRegistry.list(), archiveWorkspaceRecord: (workspaceId, options) => this.archiveWorkspaceRecord(workspaceId, options), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => @@ -6488,6 +6491,7 @@ export class Session { { scope: { kind: "workspace", workspaceId: existing.workspaceId }, requestId: request.requestId, + releaseBacking: true, }, ); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 91631a5631..cf188afc2f 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -70,6 +70,7 @@ function createHarness(input?: { directories?: string[]; paseoHome?: string; worktreesRoot?: string; + inspectRuntime?: (workspaceId: string) => Promise<"missing" | "paused" | "ready" | "error">; }) { const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace; const project = input?.project === undefined ? createProject() : input.project; @@ -85,6 +86,7 @@ function createHarness(input?: { unarchiveWorkspace: async (record) => { unarchived.push(record.workspaceId); }, + inspectRuntime: input?.inspectRuntime, }); return { service, unarchived }; } @@ -117,6 +119,42 @@ describe("workspace recovery", () => { expect(unarchived).toEqual([workspace.workspaceId]); }); + test("unarchives a ready archived runtime without presenting it as a restore", async () => { + const workspace = createWorkspace({ runtime: { runtimeId: "worktree" } }); + const { service, unarchived } = createHarness({ + workspace, + inspectRuntime: async () => "ready", + }); + + await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "unarchive", + }); + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, + action: "unarchive", + }); + expect(unarchived).toEqual([workspace.workspaceId]); + }); + + test("restores a paused archived runtime through its runtime-aware unarchive owner", async () => { + const workspace = createWorkspace({ runtime: { runtimeId: "worktree" } }); + const { service, unarchived } = createHarness({ + workspace, + inspectRuntime: async () => "paused", + }); + + await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "restore", + }); + await expect(service.restore(workspace.workspaceId)).resolves.toEqual({ + workspaceId: workspace.workspaceId, + action: "restore", + }); + expect(unarchived).toEqual([workspace.workspaceId]); + }); + test("does not offer recovery for a missing non-worktree directory", async () => { const workspace = createWorkspace({ kind: "directory", branch: null }); const { service } = createHarness({ workspace }); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index a10ec83737..dd5536c9ec 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -54,6 +54,11 @@ type RecoveryPlan = state: Extract; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string; + } + | { + kind: "runtime-restore"; + state: Extract; + workspace: PersistedWorkspaceRecord; }; type UnavailableRecoveryState = Extract; @@ -108,9 +113,12 @@ export function createWorkspaceRecoveryService(deps: { }; } const runtimeStatus = await deps.inspectRuntime(workspaceId); - if (runtimeStatus === "ready" || runtimeStatus === "paused") { + if (runtimeStatus === "ready") { return createRecoveryPlan({ action: "unarchive", workspace }); } + if (runtimeStatus === "paused") { + return createRecoveryPlan({ action: "runtime-restore", workspace }); + } return { kind: "unavailable", workspaceId, @@ -172,7 +180,7 @@ export function createWorkspaceRecoveryService(deps: { await recreateArchivedWorktree(resolved.workspace, resolved.sourceRepoRoot); } await deps.unarchiveWorkspace(resolved.workspace); - return { workspaceId, action: resolved.kind }; + return { workspaceId, action: resolved.state.action }; } async function recreateArchivedWorktree( @@ -259,7 +267,8 @@ export function createWorkspaceRecoveryService(deps: { function createRecoveryPlan( input: | { action: "unarchive"; workspace: PersistedWorkspaceRecord } - | { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string }, + | { action: "restore"; workspace: PersistedWorkspaceRecord; sourceRepoRoot: string } + | { action: "runtime-restore"; workspace: PersistedWorkspaceRecord }, ): RecoveryPlan { const state = { kind: "recoverable" as const, @@ -275,6 +284,13 @@ function createRecoveryPlan( sourceRepoRoot: input.sourceRepoRoot, }; } + if (input.action === "runtime-restore") { + return { + kind: input.action, + state: { ...state, action: "restore" }, + workspace: input.workspace, + }; + } return { kind: input.action, state: { diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index e8bda76d79..604216b639 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -19,6 +19,7 @@ import { createWorktree, type WorktreeConfig } from "../utils/worktree.js"; import type { ManagedAgent } from "./agent/agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import { archiveByScope, type ActiveWorkspaceRef, @@ -196,6 +197,35 @@ function assertArchiveResult( } describe("archiveByScope", () => { + test("reports a target teardown failure after settling the archive operation", async () => { + const { tempDir, repoDir } = createGitRepo(); + const workspaceId = "ws-failed-runtime-archive"; + const deps = createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [ + { + workspaceId, + cwd: repoDir, + kind: "local_checkout", + runtimeId: "local", + hostVisiblePath: repoDir, + }, + ], + }); + deps.archiveWorkspaceRecord = vi.fn(async () => { + throw new Error("runtime archive failed"); + }); + + await expect( + archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-failed-runtime-archive", + }), + ).rejects.toThrow("Failed to archive 1 workspace"); + + expect(deps.clearWorkspaceArchiving).toHaveBeenCalledWith([workspaceId]); + }); + test("an external runtime never exposes its host source checkout as removable backing", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -265,6 +295,78 @@ describe("archiveByScope", () => { ); }); + test("releases an archived runtime owner when the final sibling reference is archived", async () => { + const { tempDir, repoDir } = createGitRepo(); + const ownerWorkspaceId = "ws-deferred-owner"; + const siblingWorkspaceId = "ws-final-sibling"; + const deps = createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [ + { + workspaceId: siblingWorkspaceId, + cwd: repoDir, + hostVisiblePath: repoDir, + kind: "local_checkout", + runtimeId: "local", + }, + ], + }); + let siblingArchived = false; + const archiveWorkspaceRecord = deps.archiveWorkspaceRecord; + const archiveCalls: Array<{ workspaceId: string; releaseBacking: boolean }> = []; + deps.archiveWorkspaceRecord = async (workspaceId, options) => { + archiveCalls.push({ workspaceId, releaseBacking: options?.releaseBacking === true }); + if (workspaceId === siblingWorkspaceId) { + siblingArchived = true; + await archiveWorkspaceRecord(workspaceId, options); + } + }; + const workspaceRecord = ( + workspaceId: string, + runtimeId: string, + archivedAt: string | null, + ): PersistedWorkspaceRecord => ({ + workspaceId, + projectId: "project-deferred-owner", + cwd: repoDir, + hostVisiblePath: repoDir, + kind: "worktree", + displayName: workspaceId, + title: null, + branch: null, + worktreeRoot: repoDir, + baseBranch: null, + isPaseoOwnedWorktree: runtimeId === "worktree", + mainRepoRoot: repoDir, + runtime: { runtimeId }, + deletionRequestedAt: null, + createdAt: "2026-08-14T00:00:00.000Z", + updatedAt: "2026-08-14T00:00:00.000Z", + archivedAt, + autoArchivedChangeRequestUrl: null, + pinnedAt: null, + }); + deps.listWorkspaceRecords = async () => [ + workspaceRecord(ownerWorkspaceId, "worktree", "2026-08-14T00:00:00.000Z"), + workspaceRecord( + siblingWorkspaceId, + "local", + siblingArchived ? "2026-08-14T00:01:00.000Z" : null, + ), + ]; + + await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId: siblingWorkspaceId }, + requestId: "req-release-deferred-owner", + releaseBacking: true, + }); + + expect(archiveCalls).toEqual([ + { workspaceId: siblingWorkspaceId, releaseBacking: true }, + { workspaceId: ownerWorkspaceId, releaseBacking: true }, + ]); + }); + test("workspace scope archives the record and removes the directory on last reference", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -605,14 +707,14 @@ describe("archiveByScope", () => { return originalArchiveWorkspaceRecord(workspaceId); }; - const result = await archiveByScope(deps, { - scope: { kind: "worktree", targetPath: worktree.worktreePath }, - requestId: "req-partial-failure", - }); + await expect( + archiveByScope(deps, { + scope: { kind: "worktree", targetPath: worktree.worktreePath }, + requestId: "req-partial-failure", + }), + ).rejects.toThrow("Failed to archive 1 workspace"); - expect(result.archivedWorkspaceIds).toEqual([workspaceB]); - expect(result.archivedWorkspaceIds).not.toContain(workspaceA); - expect(result.removedDirectory).toBe(false); + expect(deps.activeWorkspaces.map((workspace) => workspace.workspaceId)).toEqual([workspaceA]); expect(existsSync(worktree.worktreePath)).toBe(true); }); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 59e000c07b..a65f2bb3f0 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -44,6 +44,9 @@ export interface ArchiveDependencies { // reject ambiguous same-cwd matches when archiving by path without an explicit // workspaceId. listActiveWorkspaces: () => Promise; + // Complete durable workspace set, including archived runtime owners whose + // backing may have been retained while another workspace referenced it. + listWorkspaceRecords?: () => Promise; archiveWorkspaceRecord: ( workspaceId: string, options?: { releaseBacking?: boolean }, @@ -169,13 +172,28 @@ async function archiveByScopeWithPriority( new Set(targetWorkspaceIds), dependencies, ))); - const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( + const { archivedAgents, archivedWorkspaceIds, failures } = await archiveTargetRecords( dependencies, targetWorkspaceIds, request.requestId, releaseRuntimeBacking, ); + if ( + releaseRuntimeBacking && + target.runtimeReferencePath !== null && + archivedWorkspaceIds.length > 0 + ) { + failures.push( + ...(await releaseDeferredRuntimeBackings( + dependencies, + target.runtimeReferencePath, + new Set(archivedWorkspaceIds), + request.requestId, + )), + ); + } + if (target.backing?.mainRepoRoot) { try { await dependencies.workspaceGitService.getSnapshot(target.backing.mainRepoRoot, { @@ -199,6 +217,13 @@ async function archiveByScopeWithPriority( ); } + if (failures.length > 0) { + throw new AggregateError( + failures, + `Failed to archive ${failures.length} workspace${failures.length === 1 ? "" : "s"}`, + ); + } + return { archivedAgentIds: Array.from(archivedAgents), archivedWorkspaceIds, @@ -212,6 +237,42 @@ async function archiveByScopeWithPriority( } } +async function releaseDeferredRuntimeBackings( + dependencies: ArchiveDependencies, + referencePath: string, + justArchivedWorkspaceIds: ReadonlySet, + requestId: string, +): Promise { + const matchesReferencePath = createRealpathAwarePathMatcher(referencePath); + const deferredOwners = ((await dependencies.listWorkspaceRecords?.()) ?? []).filter( + (workspace) => + workspace.archivedAt !== null && + workspace.runtime !== undefined && + !justArchivedWorkspaceIds.has(workspace.workspaceId) && + workspace.hostVisiblePath !== null && + matchesReferencePath(workspace.hostVisiblePath), + ); + const results = await Promise.allSettled( + deferredOwners.map((workspace) => + dependencies.archiveWorkspaceRecord(workspace.workspaceId, { releaseBacking: true }), + ), + ); + const failures: unknown[] = []; + for (const [index, result] of results.entries()) { + if (result?.status !== "rejected") continue; + failures.push(result.reason); + dependencies.sessionLogger?.warn( + { + err: result.reason, + requestId, + workspaceId: deferredOwners[index]?.workspaceId, + }, + "Deferred workspace runtime backing release failed", + ); + } + return failures; +} + async function resolveArchiveTarget( dependencies: ArchiveDependencies, scope: ArchiveScope, @@ -364,9 +425,14 @@ async function archiveTargetRecords( targetWorkspaceIds: string[], requestId: string, releaseBacking: boolean, -): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { +): Promise<{ + archivedAgents: Set; + archivedWorkspaceIds: string[]; + failures: unknown[]; +}> { const archivedAgents = new Set(); const archivedWorkspaceIds: string[] = []; + const failures: unknown[] = []; const results = await Promise.allSettled( targetWorkspaceIds.map(async (workspaceId) => { @@ -383,14 +449,15 @@ async function archiveTargetRecords( archivedAgents.add(agentId); } } else { + failures.push(result.reason); dependencies.sessionLogger?.warn( { err: result.reason, requestId }, - "archiveByScope workspace teardown failed; continuing", + "archiveByScope workspace teardown failed", ); } } - return { archivedAgents, archivedWorkspaceIds }; + return { archivedAgents, archivedWorkspaceIds, failures }; } async function maybeRemoveDirectory( diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index 7cab3f1a72..0f410b3083 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -46,6 +46,13 @@ export function createWorktreeRuntime(options: { async function inspect(workspaceId: string): Promise { const state = await states.read(workspaceId); if (!state) return { status: "missing" }; + if (state.lifecycle === "paused") { + return { + status: "paused", + state: publicState(state), + placement: hostPlacement(state.root), + }; + } try { if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; return { @@ -306,6 +313,18 @@ async function rematerializeOwnedWorktree( if (!branchName) { throw new Error(`Workspace runtime worktree has no restorable branch: ${state.workspaceId}`); } + try { + if (!(await stat(state.sourceRoot)).isDirectory()) { + throw new Error("The source repository needed to restore this worktree no longer exists."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("The source repository needed to restore this worktree no longer exists.", { + cause: error, + }); + } + throw error; + } await mkdir(path.dirname(state.worktreeRoot), { recursive: true }); await runGitCommand(["worktree", "add", state.worktreeRoot, branchName], { cwd: state.sourceRoot, diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 51dde7c6fc..d453cc1e01 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -1,6 +1,15 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -449,6 +458,9 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) if (runtimeId === "worktree") { expect(existsSync(runtimeRoot)).toBe(false); } + await expect(fixture.service.inspect(fixture.workspaceId)).resolves.toMatchObject({ + status: "paused", + }); await Promise.all([ fixture.service.restore(fixture.workspaceId), @@ -697,6 +709,22 @@ posixDescribe("worktree exact restore", () => { await fixture.service.destroy(fixture.workspaceId); }); + test("reports a missing source repository before attempting rematerialization", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + const displacedRepo = `${fixture.repo}-missing`; + await rename(fixture.repo, displacedRepo); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "The source repository needed to restore this worktree no longer exists.", + ); + + await rename(displacedRepo, fixture.repo); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + test("fails closed when the saved branch is checked out elsewhere", async () => { const fixture = await createFixture("worktree", { lifecycleRecords: true }); await fixture.service.create(fixture.createInput); diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index cedc5013c9..60d49bbf8d 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -142,6 +142,7 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "worktree", targetPath }, requestId: input.requestId, + releaseBacking: true, }); return { @@ -167,6 +168,7 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "workspace", workspaceId }, requestId: input.requestId, + releaseBacking: true, }); return {