mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
fix(workspaces): release archived runtime backing
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+38
@@ -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 });
|
||||
|
||||
+19
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user