fix(workspaces): preserve runtime lifecycle boundaries

This commit is contained in:
Mohamed Boudra
2026-08-14 18:35:07 +02:00
parent 51193e7a92
commit cd429339a0
23 changed files with 600 additions and 96 deletions
+10 -1
View File
@@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
message={item.text}
timestamp={item.timestamp.getTime()}
workspaceRoot={workspaceRoot}
workspaceId={context.workspaceId}
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
@@ -658,7 +659,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
</AssistantFileLinkResolverProvider>
);
},
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
[
agentId,
client,
context.workspaceId,
handleInlinePathPress,
resolvedServerId,
toast,
workspaceRoot,
],
);
const renderThoughtItem = useCallback(
@@ -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",
@@ -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<T>(input: {
@@ -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" }]);
});
});
@@ -8,7 +8,7 @@ import {
} from "./acquisition-cache";
export interface AssistantImageFileAcquisitionPort {
readFile(cwd: string, path: string): Promise<FileReadResult>;
readFile(cwd: string, path: string, workspaceId?: string): Promise<FileReadResult>;
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,
@@ -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],
@@ -187,13 +187,13 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
return (
<>
<CompactExplorerOpenGestureSurface
enabled={enabled && Boolean(model?.workspaceRoot)}
enabled={enabled && Boolean(model?.workspaceRoot) && Boolean(workspaceGit)}
onOpenExplorer={handleOpenExplorer}
>
{children}
</CompactExplorerOpenGestureSurface>
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
{enabled && model ? (
{enabled && model && workspaceGit ? (
<CompactExplorerSidebar
serverId={model.serverId}
workspaceId={model.workspaceId}
+16 -1
View File
@@ -736,6 +736,7 @@ interface AssistantMessageProps {
message: string;
timestamp: number;
workspaceRoot?: string;
workspaceId?: string;
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
@@ -799,6 +800,7 @@ function AssistantMarkdownImage({
hasLeadingContent,
client,
workspaceRoot,
workspaceId,
serverId,
}: {
source: string;
@@ -807,6 +809,7 @@ function AssistantMarkdownImage({
hasLeadingContent: boolean;
client?: DaemonClient | null;
workspaceRoot?: string;
workspaceId?: string;
serverId?: string;
}) {
const containerStyle = useMemo<StyleProp<ViewStyle>>(
@@ -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(
@@ -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,
@@ -28,7 +28,10 @@ interface CreateAgentLifecycleDispatchDependencies {
archiveAgentForClose: (agentId: string) => Promise<unknown>;
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
archiveWorkspaceRecord: (
workspaceId: string,
options?: { releaseBacking?: boolean },
) => Promise<void>;
destroyWorkspace: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => Promise<void>;
@@ -227,6 +230,7 @@ export class CreateAgentLifecycleDispatch {
{
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
requestId: randomUUID(),
releaseBacking: true,
},
);
+4 -2
View File
@@ -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,
@@ -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);
+48 -28
View File
@@ -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<void> {
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<void> {
const archiveTimestamp = archivedAt ?? new Date().toISOString();
private async archiveWorkspaceRecord(
workspaceId: string,
options?: { archivedAt?: string; releaseBacking?: boolean },
): Promise<void> {
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) =>
@@ -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 () => {
@@ -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 () => {
@@ -44,7 +44,10 @@ export interface ArchiveDependencies {
// reject ambiguous same-cwd matches when archiving by path without an explicit
// workspaceId.
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
archiveWorkspaceRecord: (
workspaceId: string,
options?: { releaseBacking?: boolean },
) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => 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<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
): Promise<BackingDirectory> {
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<string>; archivedWorkspaceIds: string[] }> {
const archivedAgents = new Set<string>();
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<boolean> {
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) {
@@ -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 });
});
@@ -39,14 +39,14 @@ export function createWorkspaceGitDirectory(options: {
function bindRecord(
record: Pick<PersistedWorkspaceRecord, "workspaceId" | "cwd" | "runtime">,
): 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);
@@ -116,6 +116,8 @@ export interface WorkspaceRuntimeDriver {
listener: () => void,
): Promise<{ unsubscribe(): Promise<void> }>;
pause(workspaceId: WorkspaceId): Promise<void>;
/** Release restorable backing owned by this driver after the runtime is paused. */
releaseBacking?(workspaceId: WorkspaceId): Promise<void>;
resume(workspaceId: WorkspaceId): Promise<WorkspaceDriverReady>;
destroy(workspaceId: WorkspaceId): Promise<void>;
reconcile?(workspaceIds: readonly WorkspaceId[]): Promise<void>;
@@ -145,7 +145,7 @@ export interface WorkspaceRuntimeService {
requireHostVisiblePath(workspaceId: string): Promise<string>;
pause(workspaceId: string): Promise<void>;
resume(workspaceId: string): Promise<void>;
archive(workspaceId: string): Promise<void>;
archive(workspaceId: string, options?: { releaseBacking?: boolean }): Promise<void>;
restore(workspaceId: string): Promise<void>;
destroy(workspaceId: string): Promise<void>;
}
@@ -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);
});
},
@@ -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<Record<string, string>>;
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<string | undefined> {
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<WorktreeRuntimeState> {
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<WorktreeRuntimeState | null> {
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 };
}
@@ -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");