From cd429339a067e8b008090fb8e58afdc5960f424d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 18:35:07 +0200 Subject: [PATCH] fix(workspaces): preserve runtime lifecycle boundaries --- packages/app/src/agent-stream/view.tsx | 11 +- .../assistant-image/acquisition-cache.test.ts | 19 +++ .../src/assistant-image/acquisition-cache.ts | 6 +- .../assistant-image/file-acquisition.test.ts | 26 +++- .../src/assistant-image/file-acquisition.ts | 7 +- .../assistant-image/use-assistant-image.ts | 8 +- .../compact-explorer-sidebar-host.tsx | 4 +- packages/app/src/components/message.tsx | 17 ++- .../screens/workspace/workspace-screen.tsx | 6 +- .../agent/create-agent-lifecycle-dispatch.ts | 6 +- packages/server/src/server/bootstrap.ts | 6 +- ...ate-agent-worktree-autoarchive.e2e.test.ts | 16 +- packages/server/src/server/session.ts | 76 ++++++---- .../src/server/session.workspaces.test.ts | 27 ++++ .../server/workspace-archive-service.test.ts | 84 ++++++++++- .../src/server/workspace-archive-service.ts | 63 ++++++-- .../server/workspace-git-directory.test.ts | 10 +- .../src/server/workspace-git-directory.ts | 18 +-- .../server/workspace-runtime/drivers/index.ts | 2 + .../src/server/workspace-runtime/index.ts | 2 +- .../workspace-runtime/internal/service.ts | 5 +- .../internal/worktree-runtime.ts | 140 +++++++++++++++++- .../workspace-runtime.posix.test.ts | 137 ++++++++++++++++- 23 files changed, 600 insertions(+), 96 deletions(-) diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 58174eae19..e46dbe2cbe 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], + [ + agentId, + client, + context.workspaceId, + handleInlinePathPress, + resolvedServerId, + toast, + workspaceRoot, + ], ); const renderThoughtItem = useCallback( diff --git a/packages/app/src/assistant-image/acquisition-cache.test.ts b/packages/app/src/assistant-image/acquisition-cache.test.ts index ce1961b632..5eafebe8ef 100644 --- a/packages/app/src/assistant-image/acquisition-cache.test.ts +++ b/packages/app/src/assistant-image/acquisition-cache.test.ts @@ -94,6 +94,25 @@ describe("assistant image acquisition cache", () => { expect(laterMessage).not.toBe(first); }); + it("scopes same-cwd file acquisitions to the selected workspace", () => { + const first = createAssistantImageFileAcquisitionKey({ + serverId: "server", + workspaceId: "workspace-1", + occurrenceKey: "message:image", + cwd: "/workspace", + path: "screenshot.png", + }); + const second = createAssistantImageFileAcquisitionKey({ + serverId: "server", + workspaceId: "workspace-2", + occurrenceKey: "message:image", + cwd: "/workspace", + path: "screenshot.png", + }); + + expect(second).not.toBe(first); + }); + it("scopes persisted file previews to the rendered message occurrence", () => { const first = createAssistantImageFilePreviewAttachmentId({ serverId: "server-1", diff --git a/packages/app/src/assistant-image/acquisition-cache.ts b/packages/app/src/assistant-image/acquisition-cache.ts index f76f594b88..196ed71837 100644 --- a/packages/app/src/assistant-image/acquisition-cache.ts +++ b/packages/app/src/assistant-image/acquisition-cache.ts @@ -19,6 +19,7 @@ export function createAssistantImageOccurrenceKey(input: { export function createAssistantImageFilePreviewAttachmentId(input: { serverId?: string; + workspaceId?: string; occurrenceKey: string; mimeType: string; path: string; @@ -32,17 +33,18 @@ export function createAssistantImageFilePreviewAttachmentId(input: { size: input.size, modifiedAt: input.modifiedAt, contentLength: input.contentLength, - contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`, + contentKey: `${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}`, }); } export function createAssistantImageFileAcquisitionKey(input: { serverId?: string; + workspaceId?: string; occurrenceKey: string; cwd: string; path: string; }): string { - return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`; + return `file:${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}:${input.cwd}:${input.path}`; } export function createAssistantImageAcquisitionCache(input: { diff --git a/packages/app/src/assistant-image/file-acquisition.test.ts b/packages/app/src/assistant-image/file-acquisition.test.ts index 552609e75f..bf61409ae6 100644 --- a/packages/app/src/assistant-image/file-acquisition.test.ts +++ b/packages/app/src/assistant-image/file-acquisition.test.ts @@ -6,10 +6,10 @@ import { } from "./file-acquisition"; class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort { - readonly reads: Array<{ cwd: string; path: string }> = []; + readonly reads: Array<{ cwd: string; path: string; workspaceId?: string }> = []; - async readFile(cwd: string, path: string) { - this.reads.push({ cwd, path }); + async readFile(cwd: string, path: string, workspaceId?: string) { + this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) }); return { kind: "image" as const, path, @@ -38,6 +38,7 @@ describe("assistant image file acquisition", () => { const common = { resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" }, serverId: "server", + workspaceId: "workspace-selected", occurrenceKey: "agent:message:reconnect-image", unavailableMessage: "Image unavailable", }; @@ -48,6 +49,23 @@ describe("assistant image file acquisition", () => { expect(disconnected?.key).toBe(connected?.key); await expect(disconnected?.locate()).rejects.toThrow("Image unavailable"); await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" }); - expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]); + expect(connectedPort.reads).toEqual([ + { cwd: "/workspace", path: "reconnect.png", workspaceId: "workspace-selected" }, + ]); + }); + + it("keeps legacy file acquisition cwd-compatible when workspaceId is absent", async () => { + const port = new MemoryFileAcquisitionPort(); + const acquisition = createAssistantImageFileAcquisition({ + port, + resolution: { kind: "file_rpc", cwd: "/legacy", path: "preview.png" }, + serverId: "server", + occurrenceKey: "agent:message:legacy-image", + unavailableMessage: "Image unavailable", + }); + + await acquisition?.locate(); + + expect(port.reads).toEqual([{ cwd: "/legacy", path: "preview.png" }]); }); }); diff --git a/packages/app/src/assistant-image/file-acquisition.ts b/packages/app/src/assistant-image/file-acquisition.ts index f7a80e8536..9f1a3959b4 100644 --- a/packages/app/src/assistant-image/file-acquisition.ts +++ b/packages/app/src/assistant-image/file-acquisition.ts @@ -8,7 +8,7 @@ import { } from "./acquisition-cache"; export interface AssistantImageFileAcquisitionPort { - readFile(cwd: string, path: string): Promise; + readFile(cwd: string, path: string, workspaceId?: string): Promise; persist(input: { id: string; bytes: Uint8Array; @@ -26,6 +26,7 @@ export function createAssistantImageFileAcquisition(input: { port: AssistantImageFileAcquisitionPort | null; resolution: AssistantImageSourceResolution | null; serverId?: string; + workspaceId?: string; occurrenceKey: string; unavailableMessage: string; }): AssistantImageAcquisition | null { @@ -36,6 +37,7 @@ export function createAssistantImageFileAcquisition(input: { return { key: createAssistantImageFileAcquisitionKey({ serverId: input.serverId, + workspaceId: input.workspaceId, occurrenceKey: input.occurrenceKey, cwd: resolution.cwd, path: resolution.path, @@ -44,13 +46,14 @@ export function createAssistantImageFileAcquisition(input: { if (!port) { throw new Error(input.unavailableMessage); } - const file = await port.readFile(resolution.cwd, resolution.path); + const file = await port.readFile(resolution.cwd, resolution.path, input.workspaceId); if (file.kind !== "image") { throw new Error(input.unavailableMessage); } return await port.persist({ id: createAssistantImageFilePreviewAttachmentId({ serverId: input.serverId, + workspaceId: input.workspaceId, occurrenceKey: input.occurrenceKey, mimeType: file.mime, path: file.path || resolution.path, diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index 0c0818e8f1..725a9d7d93 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -82,6 +82,7 @@ interface UseAssistantImageInput { client?: DaemonClient | null; workspaceRoot?: string; serverId?: string; + workspaceId?: string; } type PreviewUrlState = @@ -377,6 +378,7 @@ export function useAssistantImage({ client, workspaceRoot, serverId, + workspaceId, }: UseAssistantImageInput): AssistantImageResult { const { t } = useTranslation(); const resolution = useMemo( @@ -387,7 +389,8 @@ export function useAssistantImage({ const fileAcquisition = useMemo(() => { const port: AssistantImageFileAcquisitionPort | null = client ? { - readFile: async (cwd, path) => await client.readFile(cwd, path), + readFile: async (cwd, path, selectedWorkspaceId) => + await client.readFile(cwd, path, undefined, selectedWorkspaceId), persist: persistAttachmentFromBytes, } : null; @@ -395,10 +398,11 @@ export function useAssistantImage({ port, resolution, serverId, + workspaceId, occurrenceKey, unavailableMessage: t("message.attachments.imagePreviewUnavailable"), }); - }, [client, occurrenceKey, resolution, serverId, t]); + }, [client, occurrenceKey, resolution, serverId, t, workspaceId]); const dataImageAcquisition = useMemo( () => createDataImageAcquisition({ source, dataImage }), [dataImage, source], diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index dfbe7ee269..f5903108c1 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -187,13 +187,13 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore return ( <> {children} - {enabled && model ? ( + {enabled && model && workspaceGit ? ( >( @@ -821,6 +824,7 @@ function AssistantMarkdownImage({ occurrenceKey, client, workspaceRoot, + workspaceId, serverId, }); const binding = image.status === "failed" ? null : image.binding; @@ -1448,6 +1452,7 @@ export const AssistantMessage = memo(function AssistantMessage({ message, timestamp: _timestamp, workspaceRoot, + workspaceId, serverId, client, spacing = "default", @@ -1885,12 +1890,22 @@ export const AssistantMessage = memo(function AssistantMessage({ hasLeadingContent={hasLeadingContent} client={client} workspaceRoot={workspaceRoot} + workspaceId={workspaceId} serverId={serverId} /> ); }, }; - }, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]); + }, [ + client, + fileLinkActions, + markdownParser, + occurrenceKey, + phase, + serverId, + workspaceId, + workspaceRoot, + ]); const blocks = useMemo(() => splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5c46a4809b..6f84524e99 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3689,8 +3689,10 @@ function WorkspaceScreenContent({ [isFocusModeEnabled, isMobile], ); const showExplorerSidebar = useMemo( - () => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }), - [isRouteFocused, isFocusModeEnabled, isMobile], + () => + Boolean(workspaceGit) && + shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }), + [isRouteFocused, isFocusModeEnabled, isMobile, workspaceGit], ); const createTerminalDisabled = useMemo( () => createTerminalMutation.isPending || pendingTerminalCreateInput !== null, 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 35bc340cf4..29716ea029 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -28,7 +28,10 @@ interface CreateAgentLifecycleDispatchDependencies { archiveAgentForClose: (agentId: string) => Promise; findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; - archiveWorkspaceRecord: (workspaceId: string) => Promise; + archiveWorkspaceRecord: ( + workspaceId: string, + options?: { releaseBacking?: boolean }, + ) => Promise; destroyWorkspace: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; emitAgentRemove: (agentId: string) => Promise; @@ -227,6 +230,7 @@ export class CreateAgentLifecycleDispatch { { scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId }, requestId: randomUUID(), + releaseBacking: true, }, ); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index bb25823544..f4ba00ff01 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1142,12 +1142,14 @@ export async function createPaseoDaemon( }); const archiveWorkspaceRecordExternal = async ( workspaceId: string, - context?: WorkspaceArchiveContext, + context?: WorkspaceArchiveContext & { releaseBacking?: boolean }, ) => { const workspace = await workspaceRegistry.get(workspaceId); if (workspace?.runtime) { if (!workspaceRuntime) throw new Error(`Workspace runtime is not available: ${workspaceId}`); - await workspaceRuntime.archive(workspaceId); + await workspaceRuntime.archive(workspaceId, { + releaseBacking: context?.releaseBacking, + }); if (context?.autoArchivedChangeRequestUrl) { await workspaceRegistry.update(workspaceId, (archived) => ({ ...archived, 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 94e45d7452..753eb10bd9 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 @@ -98,6 +98,7 @@ async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; branchName?: string; repoDir?: string; + startIdle?: boolean; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { const repoDir = options?.repoDir ?? createGitRepo(); const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`; @@ -112,7 +113,7 @@ async function createAgentInBranchOffWorktree(options?: { base: "main", }, ...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), - initialPrompt: "Say done.", + ...(options?.startIdle ? {} : { initialPrompt: "Say done." }), }); return { repoDir, agentId: created.id, worktreePath: created.cwd }; } @@ -156,10 +157,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); // Archived tabs can continue asking for history. These reads must not recreate // the removed workspace observation or compromise the next agent lifecycle. - const staleTimelineReads = await Promise.allSettled( + await Promise.allSettled( Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })), ); - expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true); + expect(existsSync(created.cwd)).toBe(false); const subsequent = await ctx.client.createAgent({ config: { ...getFullAccessConfig("codex"), cwd: repoDir }, initialPrompt: "Say done.", @@ -335,19 +336,19 @@ 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 removes the directory on last reference", async () => { +test("archiving a created worktree preserves it 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 expectWorktreeListEmpty(created.repoDir); - expect(existsSync(created.worktreePath)).toBe(false); + await expectWorktreePresentInList(created.repoDir, created.worktreePath); + expect(existsSync(created.worktreePath)).toBe(true); }); test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { - const created = await createAgentInBranchOffWorktree({ autoArchive: true }); + const created = await createAgentInBranchOffWorktree({ autoArchive: true, startIdle: true }); // Create a sibling workspace that shares the same backing directory. const sibling = await ctx.client.createWorkspace({ @@ -358,6 +359,7 @@ test("auto-archiving a created worktree keeps the directory when a sibling works throw new Error(sibling.error ?? "Failed to create sibling workspace"); } + await ctx.client.sendMessage(created.agentId, "Say done."); await ctx.client.waitForFinish(created.agentId, 10000); await expectAgentAbsentFromActiveList(created.agentId); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9af0ebac8f..2dc3632e0a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -953,8 +953,10 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildProjectPlacementForWorkspaceId: (workspaceId) => this.buildProjectPlacementForWorkspaceId(workspaceId), - emitWorkspaceUpdateForWorkspaceId: (workspaceId) => - this.emitWorkspaceUpdateForWorkspaceId(workspaceId), + emitWorkspaceUpdateForWorkspaceId: (workspaceId) => { + this.workspacesAwaitingInitialAgent.delete(workspaceId); + return this.emitWorkspaceUpdateForWorkspaceId(workspaceId); + }, sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) => this.directorySync.sequenceAgentUpdate( payload, @@ -976,7 +978,8 @@ export class Session { archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + archiveWorkspaceRecord: (workspaceId, archiveOptions) => + this.archiveWorkspaceRecord(workspaceId, archiveOptions), destroyWorkspace: async (workspaceId) => { if (!this.workspaceRuntime) { throw new Error(`Workspace runtime is not available: ${workspaceId}`); @@ -1228,26 +1231,22 @@ export class Session { workspace: WorkspaceDescriptorPayload, optimisticStatus?: WorkspaceDescriptorPayload["status"], ): Promise { - try { - if (this.workspaceUpdatesSubscription) { - await this.emitWorkspaceUpdatesForWorkspaceIds( - [workspace.id], - optimisticStatus ? { optimisticStatus } : undefined, - ); - return; - } - // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. - // Older clients create before subscribing and require the causal update beside the response. - this.emit({ - type: "workspace_update", - payload: { - kind: "upsert", - workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace, - }, - }); - } finally { - if (optimisticStatus) this.workspacesAwaitingInitialAgent.delete(workspace.id); + if (this.workspaceUpdatesSubscription) { + await this.emitWorkspaceUpdatesForWorkspaceIds( + [workspace.id], + optimisticStatus ? { optimisticStatus } : undefined, + ); + return; } + // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. + // Older clients create before subscribing and require the causal update beside the response. + this.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace, + }, + }); } markWorkspaceArchivingForExternalMutation( @@ -3218,6 +3217,7 @@ export class Session { let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null; let createdAgentId: string | null = null; + let initialAgentWorkspaceId: string | null = null; try { const requestedCwd = resolve(config.cwd); const needsRequestedDirectory = @@ -3248,6 +3248,7 @@ export class Session { createdWorktree, workspacePromptTitle, }); + initialAgentWorkspaceId = resolvedIntent.intent.workspaceId; await this.requireCreateAgentWorkspaceReady(resolvedIntent); const { snapshot, liveSnapshot } = await createAgentCommand( @@ -3317,6 +3318,10 @@ export class Session { createdWorktree: createdWorktreeForCleanup, createdAgentId, }); + if (initialAgentWorkspaceId) { + this.workspacesAwaitingInitialAgent.delete(initialAgentWorkspaceId); + await this.emitWorkspaceUpdateForWorkspaceId(initialAgentWorkspaceId); + } const wireError = toWorktreeWireError(error); this.sessionLogger.error({ err: error }, "Failed to create agent"); if (requestId) { @@ -4064,7 +4069,8 @@ export class Session { agentStorage: this.agentStorage, findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + archiveWorkspaceRecord: (workspaceId, options) => + this.archiveWorkspaceRecord(workspaceId, options), emit: (message) => this.emit(message), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), @@ -4901,15 +4907,20 @@ export class Session { })); } - private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { - const archiveTimestamp = archivedAt ?? new Date().toISOString(); + private async archiveWorkspaceRecord( + workspaceId: string, + options?: { archivedAt?: string; releaseBacking?: boolean }, + ): Promise { + const archiveTimestamp = options?.archivedAt ?? new Date().toISOString(); const currentWorkspace = await this.workspaceRegistry.get(workspaceId); const existingWorkspace = currentWorkspace?.runtime ? await (async () => { if (!this.workspaceRuntime) { throw new Error(`Workspace runtime is not available: ${workspaceId}`); } - await this.workspaceRuntime.archive(workspaceId); + await this.workspaceRuntime.archive(workspaceId, { + releaseBacking: options?.releaseBacking, + }); return currentWorkspace; })() : await archivePersistedWorkspaceRecord({ @@ -5015,7 +5026,7 @@ export class Session { : null; const nextWorkspace = this.applyOptimisticWorkspaceStatus( filteredWorkspace, - options?.optimisticStatus, + this.resolveWorkspaceUpdateOptimisticStatus(workspaceId, options), ); const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); if ( @@ -5068,6 +5079,14 @@ export class Session { } } + private resolveWorkspaceUpdateOptimisticStatus( + workspaceId: string, + options: WorkspaceUpdateOptions | undefined, + ): WorkspaceDescriptorPayload["status"] | undefined { + if (options?.optimisticStatus) return options.optimisticStatus; + return this.workspacesAwaitingInitialAgent.has(workspaceId) ? "running" : undefined; + } + private applyOptimisticWorkspaceStatus( workspace: WorkspaceDescriptorPayload | null, optimisticStatus: WorkspaceDescriptorPayload["status"] | undefined, @@ -6329,7 +6348,8 @@ export class Session { findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd), getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), listActiveWorkspaces: () => this.listActiveWorkspaceRefs(), - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), + archiveWorkspaceRecord: (workspaceId, options) => + this.archiveWorkspaceRecord(workspaceId, options), emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) => this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds), markWorkspaceArchiving: (workspaceIds, archivingAt) => diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index a26169f3fe..7882c802a5 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9472,11 +9472,38 @@ test("workspace create emits through a matching workspace subscription", async ( firstAgentContext: { prompt: "Implement the requested change" }, }); + const createdWorkspace = Array.from(workspaces.values())[0]; + expect(createdWorkspace).toBeDefined(); + await mutationListener?.({ + kind: "upsert", + workspaceId: createdWorkspace.workspaceId, + workspace: createdWorkspace, + }); + const statuses = filterByType(emitted, "workspace_update").flatMap((message) => message.payload.kind === "upsert" ? [message.payload.workspace.status] : [], ); expect(statuses).toContain("running"); expect(statuses).not.toContain("done"); + + emitted.length = 0; + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-match-agent-failure", + workspaceId: createdWorkspace.workspaceId, + config: { provider: "unknown-provider", cwd: createdWorkspace.cwd }, + initialPrompt: "This agent cannot be created.", + attachments: [], + }); + expect(findByType(emitted, "status")?.payload).toMatchObject({ + status: "agent_create_failed", + requestId: "req-create-match-agent-failure", + }); + const failureStatuses = filterByType(emitted, "workspace_update").flatMap((message) => + message.payload.kind === "upsert" ? [message.payload.workspace.status] : [], + ); + expect(failureStatuses).toContain("done"); + expect(failureStatuses).not.toContain("running"); }); test("workspace create stays out of a non-matching workspace subscription", async () => { diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index 9d789e1d23..e8bda76d79 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -196,6 +196,75 @@ function assertArchiveResult( } describe("archiveByScope", () => { + test("an external runtime never exposes its host source checkout as removable backing", async () => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const sourceWorktree = await createPaseoOwnedWorktree( + repoDir, + paseoHome, + "external-source-decoy", + ); + const workspaceId = "ws-external-source-decoy"; + const deps = createArchiveDeps({ + paseoHome, + activeWorkspaces: [ + { + workspaceId, + cwd: "/runtime/workspace", + hostVisiblePath: sourceWorktree.worktreePath, + kind: "worktree", + runtimeId: "external-sandbox", + worktreeRoot: sourceWorktree.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + ], + }); + + const result = await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-external-source-decoy", + releaseBacking: true, + }); + + expect(result.removedDirectory).toBe(false); + expect(existsSync(sourceWorktree.worktreePath)).toBe(true); + }); + + test("host-invisible runtimes sharing a public cwd release independently", async () => { + const { tempDir } = createGitRepo(); + const workspaceId = "ws-host-invisible-a"; + const deps = createArchiveDeps({ + paseoHome: path.join(tempDir, ".paseo"), + activeWorkspaces: [ + { workspaceId, cwd: "/workspace", kind: "git", runtimeId: "remote-a" }, + { + workspaceId: "ws-host-invisible-b", + cwd: "/workspace", + kind: "git", + runtimeId: "remote-b", + }, + ], + }); + const archiveWorkspaceRecord = deps.archiveWorkspaceRecord; + const releases: boolean[] = []; + deps.archiveWorkspaceRecord = async (targetWorkspaceId, options) => { + releases.push(options?.releaseBacking === true); + await archiveWorkspaceRecord(targetWorkspaceId, options); + }; + + await archiveByScope(deps, { + scope: { kind: "workspace", workspaceId }, + requestId: "req-host-invisible-release", + releaseBacking: true, + }); + + expect(releases).toEqual([true]); + expect(deps.activeWorkspaces.map((workspace) => workspace.workspaceId)).toContain( + "ws-host-invisible-b", + ); + }); + test("workspace scope archives the record and removes the directory on last reference", async () => { const { tempDir, repoDir } = createGitRepo(); const paseoHome = path.join(tempDir, ".paseo"); @@ -694,7 +763,15 @@ describe("archiveByScope", () => { const deps = createArchiveDeps({ paseoHome, activeWorkspaces: [ - { workspaceId: targetWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" }, + { + workspaceId: targetWorkspaceId, + cwd: worktree.worktreePath, + kind: "worktree", + runtimeId: "worktree", + worktreeRoot: worktree.worktreePath, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, ], }); deps.agentManager = { @@ -724,17 +801,18 @@ describe("archiveByScope", () => { const result = await archiveByScope(deps, { scope: { kind: "workspace", workspaceId: targetWorkspaceId }, requestId: "req-snapshot-scope", + releaseBacking: true, }); assertArchiveResult(result, { archivedWorkspaceIds: [targetWorkspaceId], - removedDirectory: true, + removedDirectory: false, }); expect(result.archivedAgentIds).toContain(liveAgentId); expect(result.archivedAgentIds).toContain(targetStoredAgentId); expect(result.archivedAgentIds).not.toContain(otherStoredAgentId); expect(deps.archivedSnapshotIds).toEqual([targetStoredAgentId]); - expect(existsSync(worktree.worktreePath)).toBe(false); + expect(existsSync(worktree.worktreePath)).toBe(true); }); test("archives the durable snapshot when an observed live agent closes before teardown", async () => { diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 3d4ab9b0c4..59e000c07b 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -44,7 +44,10 @@ export interface ArchiveDependencies { // reject ambiguous same-cwd matches when archiving by path without an explicit // workspaceId. listActiveWorkspaces: () => Promise; - archiveWorkspaceRecord: (workspaceId: string) => Promise; + archiveWorkspaceRecord: ( + workspaceId: string, + options?: { releaseBacking?: boolean }, + ) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; @@ -72,6 +75,7 @@ export interface ArchiveResult { export interface ArchiveByScopeRequest { scope: ArchiveScope; requestId: string; + releaseBacking?: boolean; } export async function requireActiveWorkspaceForArchive( @@ -96,7 +100,8 @@ interface BackingDirectory { interface ArchiveTarget { backing: BackingDirectory | null; - preserveBackingDirectory: boolean; + runtimeManaged: boolean; + runtimeReferencePath: string | null; teardownTargets: Array<{ workspaceId: string | null; cwd: string }>; setupWorkspaceIds: string[]; workspaceIds: string[]; @@ -154,10 +159,21 @@ async function archiveByScopeWithPriority( await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds); } + const releaseBacking = request.releaseBacking ?? !target.runtimeManaged; + const releaseRuntimeBacking = + releaseBacking && + (target.runtimeReferencePath === null || + (await isDirectoryUnreferenced( + await dependencies.listActiveWorkspaces(), + target.runtimeReferencePath, + new Set(targetWorkspaceIds), + dependencies, + ))); const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords( dependencies, targetWorkspaceIds, request.requestId, + releaseRuntimeBacking, ); if (target.backing?.mainRepoRoot) { @@ -174,7 +190,7 @@ async function archiveByScopeWithPriority( } } - if (target.backing !== null) { + if (releaseBacking && target.backing !== null) { removedDirectory = await maybeRemoveDirectory( dependencies, request, @@ -214,21 +230,18 @@ async function resolveArchiveTarget( ); return { backing: null, - preserveBackingDirectory: false, + runtimeManaged: false, + runtimeReferencePath: null, teardownTargets: [], setupWorkspaceIds: [], workspaceIds: [], }; } const isArchived = "archivedAt" in record && Boolean(record.archivedAt); - const isRuntimeSelected = - ("runtime" in record && Boolean(record.runtime)) || - ("runtimeId" in record && Boolean(record.runtimeId)); return { - backing: isRuntimeSelected - ? null - : await resolveWorkspaceBackingDirectory(record, dependencies), - preserveBackingDirectory: isRuntimeSelected, + backing: await resolveWorkspaceBackingDirectory(record, dependencies), + runtimeManaged: hasSelectedRuntime(record), + runtimeReferencePath: hasSelectedRuntime(record) ? (record.hostVisiblePath ?? null) : null, teardownTargets: isArchived ? [] : [{ workspaceId, cwd: record.cwd }], setupWorkspaceIds: [workspaceId], workspaceIds: isArchived ? [] : [workspaceId], @@ -253,9 +266,11 @@ async function resolveArchiveTarget( ...backing, mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot, }, - preserveBackingDirectory: targetWorkspaces.some( - (workspace) => workspace.runtimeId !== null && workspace.runtimeId !== undefined, - ), + runtimeManaged: targetWorkspaces.some(hasSelectedRuntime), + runtimeReferencePath: + targetWorkspaces.find( + (workspace) => hasSelectedRuntime(workspace) && workspace.hostVisiblePath, + )?.hostVisiblePath ?? null, teardownTargets: targetWorkspaces.length > 0 ? targetWorkspaces.map((workspace) => ({ @@ -293,6 +308,14 @@ async function resolveWorkspaceBackingDirectory( workspace: ActiveWorkspaceRef, dependencies: Pick, ): Promise { + if (hasSelectedRuntime(workspace)) { + return { + path: resolve(workspace.hostVisiblePath ?? workspace.cwd), + isPaseoOwnedWorktree: false, + mainRepoRoot: workspace.mainRepoRoot ?? null, + paseoWorktreesRoot: null, + }; + } if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) { return { path: resolve(workspace.worktreeRoot), @@ -340,6 +363,7 @@ async function archiveTargetRecords( dependencies: ArchiveDependencies, targetWorkspaceIds: string[], requestId: string, + releaseBacking: boolean, ): Promise<{ archivedAgents: Set; archivedWorkspaceIds: string[] }> { const archivedAgents = new Set(); const archivedWorkspaceIds: string[] = []; @@ -347,7 +371,7 @@ async function archiveTargetRecords( const results = await Promise.allSettled( targetWorkspaceIds.map(async (workspaceId) => { const agents = await archiveWorkspaceContents(dependencies, workspaceId); - await dependencies.archiveWorkspaceRecord(workspaceId); + await dependencies.archiveWorkspaceRecord(workspaceId, { releaseBacking }); return { workspaceId, agents }; }), ); @@ -376,7 +400,7 @@ async function maybeRemoveDirectory( archivedWorkspaceIds: string[], ): Promise { const backing = target.backing; - if (target.preserveBackingDirectory || !backing?.isPaseoOwnedWorktree) { + if (!backing?.isPaseoOwnedWorktree) { return false; } @@ -442,6 +466,13 @@ async function maybeRemoveDirectory( } } +function hasSelectedRuntime(workspace: ActiveWorkspaceRef): boolean { + return ( + workspace.runtimeId != null || + ("runtime" in workspace && Boolean((workspace as { runtime?: unknown }).runtime)) + ); +} + function uniqueFilesystemPaths(paths: string[]): string[] { const unique: string[] = []; for (const candidate of paths) { diff --git a/packages/server/src/server/workspace-git-directory.test.ts b/packages/server/src/server/workspace-git-directory.test.ts index 2ce31e4a9a..3a7a7c4e0a 100644 --- a/packages/server/src/server/workspace-git-directory.test.ts +++ b/packages/server/src/server/workspace-git-directory.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { expect, test, vi } from "vitest"; import { createWorkspaceGitDirectory } from "./workspace-git-directory.js"; import type { WorkspaceGitWorkspace } from "./workspace-git-service.js"; @@ -69,13 +70,14 @@ test("legacy Git addresses remain available for host-visible runtime placements" }); await expect(directory.resolve({ kind: "legacy", cwd })).resolves.toBe(legacy); - expect(bindLegacy).toHaveBeenCalledWith(cwd); + expect(bindLegacy).toHaveBeenCalledWith(resolve(cwd)); }); test("selected Git addresses normalize both fields before registry lookup", async () => { + const runtimeCwd = process.platform === "win32" ? "/workspace" : String.raw`C:\workspace`; const record = { workspaceId: "workspace-a", - cwd: "/shared", + cwd: runtimeCwd, runtime: { runtimeId: "command" }, }; const get = vi.fn(async () => record); @@ -90,9 +92,9 @@ test("selected Git addresses normalize both fields before registry lookup", asyn directory.resolve({ kind: "selected", workspaceId: " workspace-a ", - cwd: " /shared ", + cwd: ` ${runtimeCwd} `, }), ).resolves.toBe(selected); expect(get).toHaveBeenCalledWith("workspace-a"); - expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" }); + expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: runtimeCwd }); }); diff --git a/packages/server/src/server/workspace-git-directory.ts b/packages/server/src/server/workspace-git-directory.ts index 99afef1b7c..800e05426c 100644 --- a/packages/server/src/server/workspace-git-directory.ts +++ b/packages/server/src/server/workspace-git-directory.ts @@ -39,14 +39,14 @@ export function createWorkspaceGitDirectory(options: { function bindRecord( record: Pick, ): WorkspaceGitWorkspace { - const cwd = resolve(record.cwd); + const selected = record.runtime?.runtimeId !== undefined; + const cwd = selected ? record.cwd.trim() : resolve(record.cwd); const existing = boundRecords.get(record.workspaceId); if (existing) { if (existing.cwd !== cwd) throw new Error(`Workspace Git binding changed cwd: ${record.workspaceId}`); return existing.workspaceGit; } - const selected = record.runtime?.runtimeId !== undefined; const workspaceGit = selected ? workspaceGitService.bindWorkspace({ workspaceId: record.workspaceId, @@ -61,21 +61,22 @@ export function createWorkspaceGitDirectory(options: { bindRecord, getBound(workspaceId, cwd) { const bound = boundRecords.get(workspaceId); - if (!bound || bound.cwd !== resolve(cwd)) { + const addressedCwd = bound?.selected ? cwd.trim() : resolve(cwd); + if (!bound || bound.cwd !== addressedCwd) { throw new Error(`Workspace Git is not bound: ${workspaceId}`); } return bound.workspaceGit; }, getObservationBinding(workspaceId, cwd) { - const normalizedCwd = resolve(cwd); const bound = boundRecords.get(workspaceId); - if (!bound || bound.cwd !== normalizedCwd) { + const addressedCwd = bound?.selected ? cwd.trim() : resolve(cwd); + if (!bound || bound.cwd !== addressedCwd) { throw new Error(`Workspace Git is not bound: ${workspaceId}`); } return { address: bound.selected - ? { kind: "selected", workspaceId, cwd: normalizedCwd } - : { kind: "legacy", cwd: normalizedCwd }, + ? { kind: "selected", workspaceId, cwd: addressedCwd } + : { kind: "legacy", cwd: addressedCwd }, workspaceGit: bound.workspaceGit, }; }, @@ -89,10 +90,9 @@ export function createWorkspaceGitDirectory(options: { if (selectedCwd.length === 0) { throw new Error("cwd is required for selected workspace Git"); } - const cwd = resolve(selectedCwd); const record = await workspaceRegistry.get(workspaceId); if (!record) throw new Error(`Workspace not found: ${workspaceId}`); - if (resolve(record.cwd) !== cwd) { + if (record.cwd.trim() !== selectedCwd) { throw new Error(`Workspace cwd does not match ${workspaceId}`); } return bindRecord(record); diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 2a56d3d1b7..8106d949ef 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -116,6 +116,8 @@ export interface WorkspaceRuntimeDriver { listener: () => void, ): Promise<{ unsubscribe(): Promise }>; pause(workspaceId: WorkspaceId): Promise; + /** Release restorable backing owned by this driver after the runtime is paused. */ + releaseBacking?(workspaceId: WorkspaceId): Promise; resume(workspaceId: WorkspaceId): Promise; destroy(workspaceId: WorkspaceId): Promise; reconcile?(workspaceIds: readonly WorkspaceId[]): Promise; diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 46280de574..bc19f09682 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -145,7 +145,7 @@ export interface WorkspaceRuntimeService { requireHostVisiblePath(workspaceId: string): Promise; pause(workspaceId: string): Promise; resume(workspaceId: string): Promise; - archive(workspaceId: string): Promise; + archive(workspaceId: string, options?: { releaseBacking?: boolean }): Promise; restore(workspaceId: string): Promise; destroy(workspaceId: string): Promise; } diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index b0e6d5597e..064e0efcad 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -315,7 +315,7 @@ export function createService( unavailableFiles.delete(workspaceId); }); }, - async archive(workspaceId) { + async archive(workspaceId, archiveOptions) { await sequence(workspaceId, async () => { if (!records.archiveWorkspaceRecord) { throw new Error("Workspace runtime record store cannot archive workspaces"); @@ -324,6 +324,9 @@ export function createService( const inspection = await driver.inspect(workspaceId); if (inspection.status === "ready") await runArchiveHooks(workspaceId, driver); await pauseWithDriver(workspaceId, driver); + if (archiveOptions?.releaseBacking) { + await driver.releaseBacking?.(workspaceId); + } await records.archiveWorkspaceRecord(workspaceId); }); }, 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 36c77a3765..7cab3f1a72 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -1,4 +1,4 @@ -import { stat } from "node:fs/promises"; +import { mkdir, stat } from "node:fs/promises"; import path from "node:path"; import { @@ -21,6 +21,8 @@ import { createRuntimeStateStore } from "./runtime-state.js"; import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata } from "../../../utils/worktree-metadata.js"; import { createExternalProcessEnv } from "../../paseo-env.js"; import { createStringCommandShellEnv } from "../../../utils/string-command-shell.js"; +import { runGitCommand } from "../../../utils/run-git-command.js"; +import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; interface WorktreeRuntimeState { workspaceId: string; @@ -29,6 +31,8 @@ interface WorktreeRuntimeState { worktreeRoot: string; lifecycle: "ready" | "paused"; lifecycleEnvironment: Readonly>; + branchName?: string; + relativeCwd?: string; ownsWorktree?: boolean; } @@ -162,6 +166,8 @@ export function createWorktreeRuntime(options: { PASEO_WORKTREE_PATH: worktree.worktreePath, PASEO_BRANCH_NAME: worktree.branchName, }, + branchName: worktree.branchName, + ...(input.placement.relativeCwd ? { relativeCwd: input.placement.relativeCwd } : {}), ownsWorktree: true, }; await states.write(state); @@ -193,12 +199,41 @@ export function createWorktreeRuntime(options: { const state = await states.read(workspaceId); if (state?.lifecycle === "paused") return; if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); - await states.write({ ...state, lifecycle: "paused" }); + const branchName = state.ownsWorktree === false ? undefined : await currentBranch(state); + await states.write({ + ...state, + lifecycle: "paused", + ...(branchName + ? { + branchName, + lifecycleEnvironment: { + ...state.lifecycleEnvironment, + PASEO_BRANCH_NAME: branchName, + }, + } + : {}), + }); + }, + async releaseBacking(workspaceId) { + const state = await states.read(workspaceId); + if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); + if (state.lifecycle !== "paused") { + throw new Error(`Workspace runtime worktree is not paused: ${workspaceId}`); + } + if (state.ownsWorktree === false) return; + await deletePaseoWorktree({ + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); }, async resume(workspaceId) { const current = await states.read(workspaceId); if (!current) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); - const state = { ...current, lifecycle: "ready" as const }; + const rematerialized = await rematerializeOwnedWorktree(current, options); + const state = { ...rematerialized, lifecycle: "ready" as const }; await states.write(state); return { state: publicState(state), placement: hostPlacement(state.root) }; }, @@ -241,10 +276,109 @@ function isWorktreeRuntimeState( (state.lifecycle === "ready" || state.lifecycle === "paused") && !!state.lifecycleEnvironment && typeof state.lifecycleEnvironment === "object" && + (state.branchName === undefined || typeof state.branchName === "string") && + (state.relativeCwd === undefined || typeof state.relativeCwd === "string") && (state.ownsWorktree === undefined || typeof state.ownsWorktree === "boolean") ); } +async function currentBranch(state: WorktreeRuntimeState): Promise { + const { stdout } = await runGitCommand(["branch", "--show-current"], { + cwd: state.worktreeRoot, + }); + const branchName = stdout.trim(); + if (!branchName) { + throw new Error(`Workspace runtime worktree has no current branch: ${state.workspaceId}`); + } + return branchName; +} + +async function rematerializeOwnedWorktree( + state: WorktreeRuntimeState, + options: { paseoHome: string; worktreesRoot?: string }, +): Promise { + const existing = await validateExistingWorktree(state); + if (existing) return existing; + if (state.ownsWorktree === false) { + throw new Error(`Workspace runtime worktree is missing: ${state.workspaceId}`); + } + const branchName = state.branchName ?? state.lifecycleEnvironment.PASEO_BRANCH_NAME; + if (!branchName) { + throw new Error(`Workspace runtime worktree has no restorable branch: ${state.workspaceId}`); + } + await mkdir(path.dirname(state.worktreeRoot), { recursive: true }); + await runGitCommand(["worktree", "add", state.worktreeRoot, branchName], { + cwd: state.sourceRoot, + timeout: 120_000, + }); + try { + const relativeCwd = state.relativeCwd ?? path.relative(state.worktreeRoot, state.root); + const root = await resolveRuntimeCwd(state.worktreeRoot, relativeCwd || undefined).catch( + (error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Selected project directory is missing from the worktree", { + cause: error, + }); + } + throw error; + }, + ); + return { + ...state, + root, + branchName, + ...(relativeCwd ? { relativeCwd } : {}), + lifecycleEnvironment: { + ...state.lifecycleEnvironment, + PASEO_WORKTREE_PATH: state.worktreeRoot, + PASEO_BRANCH_NAME: branchName, + }, + }; + } catch (error) { + await deletePaseoWorktree({ + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + throw error; + } +} + +async function validateExistingWorktree( + state: WorktreeRuntimeState, +): Promise { + try { + if (!(await stat(state.worktreeRoot)).isDirectory()) return null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + const { exitCode, stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd: state.worktreeRoot, + acceptExitCodes: [0, 128], + }); + if (exitCode !== 0 || !createRealpathAwarePathMatcher(state.worktreeRoot)(stdout.trim())) { + throw new Error(`Workspace runtime worktree path is occupied: ${state.worktreeRoot}`); + } + const branchName = await currentBranch(state); + const expectedBranchName = state.branchName ?? state.lifecycleEnvironment.PASEO_BRANCH_NAME; + if (!expectedBranchName || branchName !== expectedBranchName) { + throw new Error( + `Workspace runtime worktree branch changed: expected ${expectedBranchName ?? "unknown"}, received ${branchName}`, + ); + } + const relativeCwd = state.relativeCwd ?? path.relative(state.worktreeRoot, state.root); + try { + const root = await resolveRuntimeCwd(state.worktreeRoot, relativeCwd || undefined); + if ((await stat(root)).isDirectory()) return { ...state, root }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + throw new Error(`Selected project directory is missing from the worktree: ${state.root}`); +} + function hostPlacement(root: string) { return { cwd: root, hostVisiblePath: root }; } 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 765250db8a..51dde7c6fc 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 @@ -442,16 +442,20 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) }); await Promise.all([ - fixture.service.archive(fixture.workspaceId), - fixture.service.archive(fixture.workspaceId), + fixture.service.archive(fixture.workspaceId, { releaseBacking: true }), + fixture.service.archive(fixture.workspaceId, { releaseBacking: true }), ]); expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(true); + if (runtimeId === "worktree") { + expect(existsSync(runtimeRoot)).toBe(false); + } await Promise.all([ fixture.service.restore(fixture.workspaceId), fixture.service.restore(fixture.workspaceId), ]); expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false); + expect(existsSync(runtimeRoot)).toBe(true); await writeFile(path.join(runtimeRoot, "dirty-untracked.txt"), "observed\n"); await expect(restoredChange).resolves.toEqual(["dirty-untracked.txt"]); await subscription.unsubscribe(); @@ -471,9 +475,11 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) reconstructed.restore(fixture.workspaceId), ]); expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false); - expect(await readFile(path.join(runtimeRoot, "dirty-untracked.txt"), "utf8")).toBe( - "observed\n", - ); + if (runtimeId !== "worktree") { + expect(await readFile(path.join(runtimeRoot, "dirty-untracked.txt"), "utf8")).toBe( + "observed\n", + ); + } await Promise.all([ reconstructed.destroy(fixture.workspaceId), @@ -626,6 +632,127 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) }); }); +posixDescribe("worktree exact restore", () => { + test("rejects a preserved worktree whose branch changed", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId); + execFileSync("git", ["switch", "-c", "unexpected-branch"], { + cwd: runtimeRoot, + stdio: "pipe", + }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "Workspace runtime worktree branch changed: expected runtime-branch, received unexpected-branch", + ); + + execFileSync("git", ["switch", "runtime-branch"], { cwd: runtimeRoot, stdio: "pipe" }); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("rejects a selected subdirectory replaced by an escaping symlink", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await mkdir(path.join(fixture.repo, "nested")); + await writeFile(path.join(fixture.repo, "nested", "file.txt"), "nested\n"); + execFileSync("git", ["add", "."], { cwd: fixture.repo, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], { + cwd: fixture.repo, + stdio: "pipe", + }); + await fixture.service.create({ + ...fixture.createInput, + placement: { + ...(fixture.createInput.placement as Extract< + CreateWorkspaceInput["placement"], + { kind: "branch" } + >), + relativeCwd: "nested", + }, + }); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId); + await rm(path.join(runtimeRoot, "nested"), { recursive: true }); + await symlink(fixture.root, path.join(runtimeRoot, "nested")); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(); + expect(await realpath(path.join(runtimeRoot, "nested"))).toBe(await realpath(fixture.root)); + }); + + test("fails closed when the saved path is occupied", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await fixture.service.create(fixture.createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + await mkdir(runtimeRoot, { recursive: true }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + `Workspace runtime worktree path is occupied: ${runtimeRoot}`, + ); + expect(listLinkedWorktrees(fixture.repo)).not.toContain(runtimeRoot); + + await rm(runtimeRoot, { recursive: true, force: true }); + 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); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + const otherRoot = path.join(fixture.root, "branch-owner"); + execFileSync("git", ["worktree", "add", otherRoot, "runtime-branch"], { + cwd: fixture.repo, + stdio: "pipe", + }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(); + expect(existsSync(runtimeRoot)).toBe(false); + + execFileSync("git", ["worktree", "remove", "--force", otherRoot], { + cwd: fixture.repo, + stdio: "pipe", + }); + await fixture.service.restore(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("removes a partial restore when the selected subdirectory is missing", async () => { + const fixture = await createFixture("worktree", { lifecycleRecords: true }); + await mkdir(path.join(fixture.repo, "nested")); + await writeFile(path.join(fixture.repo, "nested", "file.txt"), "nested\n"); + execFileSync("git", ["add", "."], { cwd: fixture.repo, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], { + cwd: fixture.repo, + stdio: "pipe", + }); + const createInput: CreateWorkspaceInput = { + ...fixture.createInput, + placement: { + ...(fixture.createInput.placement as Extract< + CreateWorkspaceInput["placement"], + { kind: "branch" } + >), + relativeCwd: "nested", + }, + }; + await fixture.service.create(createInput); + const runtimeRoot = fixture.runtimeRoot(); + await fixture.service.archive(fixture.workspaceId, { releaseBacking: true }); + execFileSync("git", ["branch", "-f", "runtime-branch", "main~1"], { + cwd: fixture.repo, + stdio: "pipe", + }); + + await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow( + "Selected project directory is missing from the worktree", + ); + expect(existsSync(runtimeRoot)).toBe(false); + }); +}); + posixDescribe("reconstruction placement recovery", () => { test("refreshes persisted compatibility cwd from driver inspect", async () => { const root = await temporaryRoot("reconcile-compatibility-cwd");