fix(workspaces): release archived runtime backing

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