Keep restored merged workspaces active (#2714)

* fix(workspaces): keep restored merged workspaces active

Auto-archive previously reacted to every merged pull request snapshot, so restoring a workspace immediately archived it again. Persist the consumed change request with the archive transition so explicit recovery remains authoritative.

* test(e2e): clean partial archive scenarios

Track workspace resources as soon as creation succeeds so later setup failures archive the worktree and remove the registered project.

* test(workspaces): pin archive latch restoration

Cover the persisted archive marker across restore in CI and make the real browser assertion observe active workspace stability after refresh.

* fix(workspaces): latch restored legacy archives

Resolve the current merged change request while restoring records created before the archive marker existed, then persist it with the unarchive mutation.

* fix(workspaces): refresh latch on restore

When an archived workspace now points at a different merged change request, record that URL during restore so the restored workspace remains active.

* test(e2e): make archive latch setup fail loudly

Remove authentication-gated skipping and register each external fixture cleanup immediately after allocation so setup failures cannot leak resources.

* test(workspaces): include archive latch in reconciliation
This commit is contained in:
Mohamed Boudra
2026-08-01 12:42:18 +02:00
committed by GitHub
parent a059af1eda
commit ef63dd212b
11 changed files with 441 additions and 28 deletions
+18 -17
View File
@@ -490,23 +490,24 @@ workspace together with its owning project.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| Field | Type | Description |
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
@@ -0,0 +1,175 @@
import { existsSync } from "node:fs";
import { expect, test, type Page } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { clickSessionRow, expectSessionRowArchived, openSessions } from "./helpers/archive-tab";
import { cloneGithubRepoDefaultBranchOnly, createTempGithubRepo } from "./helpers/github-fixtures";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
} from "./helpers/new-workspace";
import { connectSeedClient } from "./helpers/seed-client";
import { getServerId } from "./helpers/server-id";
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
test.describe("Auto-archive after pull request merge", () => {
test.describe.configure({ retries: 0, timeout: 180_000 });
test("manual restore latches and keeps a merged pull request workspace active", async ({
page,
}) => {
const scenario = await createMergedPullRequestScenario();
try {
await scenario.autoArchive();
await openArchivedWorkspaceFromHistory(page, scenario.agentTitle);
await restoreWorkspace(page, scenario.workspaceId);
await scenario.refreshMergedPullRequest();
await scenario.expectWorkspaceRemainsActive();
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: scenario.workspaceId,
});
} finally {
await scenario.cleanup();
}
});
});
interface MergedPullRequestScenario {
agentTitle: string;
workspaceId: string;
autoArchive(): Promise<void>;
refreshMergedPullRequest(): Promise<void>;
expectWorkspaceRemainsActive(): Promise<void>;
cleanup(): Promise<void>;
}
async function createMergedPullRequestScenario(): Promise<MergedPullRequestScenario> {
const cleanups: Array<() => Promise<unknown>> = [];
try {
const repo = await createTempGithubRepo({
category: "auto-archive-latch",
prs: [{ title: "Auto-archive latch", state: "merged" }],
});
cleanups.push(() => repo.cleanup());
const checkout = await cloneGithubRepoDefaultBranchOnly(repo);
cleanups.push(() => checkout.cleanup());
const workspaceClient = await connectNewWorkspaceDaemonClient();
cleanups.push(() => workspaceClient.close());
const agentClient = await connectSeedClient();
cleanups.push(() => agentClient.close());
const previousConfig = await workspaceClient.getDaemonConfig();
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: false });
cleanups.push(() =>
workspaceClient.patchDaemonConfig({
autoArchiveAfterMerge: previousConfig.config.autoArchiveAfterMerge,
}),
);
const pullRequest = repo.prs[0];
if (!pullRequest) {
throw new Error("Expected the merged pull request fixture");
}
const created = await workspaceClient.createPaseoWorktree({
cwd: checkout.path,
action: "checkout",
checkoutSource: {
kind: "change_request",
forge: "github",
number: pullRequest.number,
},
worktreeSlug: `auto-archive-latch-${Date.now()}`,
});
if (!created.workspace || created.error) {
throw new Error(created.error ?? "Failed to create merged pull request workspace");
}
const workspace = created.workspace;
cleanups.push(() => workspaceClient.removeProject(workspace.projectId));
cleanups.push(() => archiveWorkspaceFromDaemon(workspaceClient, workspace.workspaceDirectory));
const agentTitle = `Auto-archive latch ${Date.now()}`;
const agent = await agentClient.createAgent({
provider: "mock",
model: "ten-second-stream",
modeId: "load-test",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.id,
title: agentTitle,
});
await agentClient.waitForAgentUpsert(
agent.id,
(snapshot) => snapshot.status === "idle",
30_000,
);
return {
agentTitle,
workspaceId: workspace.id,
autoArchive: async () => {
await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: true });
await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
await expect
.poll(() => agentClient.fetchAgent({ agentId: agent.id }), { timeout: 30_000 })
.toMatchObject({ agent: { archivedAt: expect.any(String) } });
await expect
.poll(() => existsSync(workspace.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
},
refreshMergedPullRequest: async () => {
const refresh = await workspaceClient.checkoutRefresh(workspace.workspaceDirectory);
if (!refresh.success) {
throw new Error(`Failed to refresh restored workspace: ${String(refresh.error)}`);
}
},
expectWorkspaceRemainsActive: async () => {
const observationDeadline = Date.now() + 5_000;
await expect
.poll(
async () => {
const descriptor = (await workspaceClient.fetchWorkspaces()).entries.find(
(entry) => entry.id === workspace.id,
);
if (!descriptor) {
return "archived";
}
return Date.now() >= observationDeadline ? "active" : "observing";
},
{ timeout: 10_000, intervals: [100, 250, 500] },
)
.toBe("active");
},
cleanup: () => runScenarioCleanups(cleanups),
};
} catch (error) {
await runScenarioCleanups(cleanups);
throw error;
}
}
async function runScenarioCleanups(cleanups: Array<() => Promise<unknown>>): Promise<void> {
while (cleanups.length > 0) {
await cleanups
.pop()?.()
.catch(() => undefined);
}
}
async function openArchivedWorkspaceFromHistory(page: Page, agentTitle: string): Promise<void> {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowArchived(page, agentTitle);
await clickSessionRow(page, agentTitle);
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
}
async function restoreWorkspace(page: Page, workspaceId: string): Promise<void> {
const action = page.getByTestId("workspace-recovery-action");
await expect(action).toHaveText("Restore");
await action.click();
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
}
@@ -79,6 +79,7 @@ function createLogger(): Logger {
function createHarness(overrides?: {
autoArchiveAfterMerge?: boolean;
autoArchivedChangeRequestUrl?: string | null;
getSnapshot?: () => Promise<WorkspaceGitRuntimeSnapshot | null>;
isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"];
@@ -105,6 +106,9 @@ function createHarness(overrides?: {
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
listActiveWorkspaces: vi.fn(async () => []),
getAutoArchivedChangeRequestUrl: vi.fn(
async () => overrides?.autoArchivedChangeRequestUrl ?? null,
),
archiveWorkspaceRecord: vi.fn(),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
@@ -316,6 +320,7 @@ function createRealOutcomeHarness(input: {
},
listActiveWorkspaces: async () =>
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
getAutoArchivedChangeRequestUrl: async () => null,
archiveWorkspaceRecord: async (workspaceId: string) => {
input.archivedWorkspaceIds.add(workspaceId);
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
@@ -496,6 +501,45 @@ describe("archiveIfSafe", () => {
expect(harness.inFlight.has(CWD)).toBe(false);
});
test("does not archive a merge event already consumed by this workspace", async () => {
const harness = createHarness({
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("archives a different merged change request", async () => {
const harness = createHarness({
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/122",
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
});
test("records the consumed change request in the workspace archive mutation", async () => {
const harness = createHarness({
archiveByScope: async (dependencies) => {
await dependencies.archiveWorkspaceRecord("ws-auto-archive");
return {
archivedAgentIds: [],
archivedWorkspaceIds: ["ws-auto-archive"],
removedDirectory: false,
};
},
});
await runArchiveIfSafe(harness);
expect(harness.options.archiveWorkspaceRecord).toHaveBeenCalledWith("ws-auto-archive", {
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
});
});
test("resolves the merged cwd to a single workspace and does not iterate siblings", async () => {
const harness = createHarness({
resolveWorkspaceIdAtPath: async () => "ws-merged-worktree",
@@ -16,6 +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";
export interface AutoArchiveArchiveOptions {
paseoHome: string;
@@ -28,7 +29,8 @@ export interface AutoArchiveArchiveOptions {
terminalManager: TerminalManager;
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
@@ -111,6 +113,11 @@ export async function archiveIfSafe(input: {
log.warn({ cwd }, "Auto-archive could not resolve a workspace for cwd; skipping");
return;
}
const autoArchivedChangeRequestUrl =
await options.getAutoArchivedChangeRequestUrl(workspaceId);
if (autoArchivedChangeRequestUrl === pullRequest.url) {
return;
}
await deps.archiveByScope(
{
@@ -122,7 +129,10 @@ export async function archiveIfSafe(input: {
agentStorage: options.agentStorage,
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
listActiveWorkspaces: options.listActiveWorkspaces,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
archiveWorkspaceRecord: (workspaceIdToArchive) =>
options.archiveWorkspaceRecord(workspaceIdToArchive, {
autoArchivedChangeRequestUrl: pullRequest.url,
}),
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
+12 -2
View File
@@ -138,7 +138,11 @@ import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
type WorkspaceArchiveContext,
} from "./workspace-registry.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
@@ -876,10 +880,14 @@ export async function createPaseoDaemon(
paseoHome: config.paseoHome,
workspaceGitService,
});
const archiveWorkspaceRecordExternal = async (workspaceId: string) => {
const archiveWorkspaceRecordExternal = async (
workspaceId: string,
context?: WorkspaceArchiveContext,
) => {
const existingWorkspace = await archivePersistedWorkspaceRecord({
workspaceId,
workspaceRegistry,
context,
});
if (!existingWorkspace || existingWorkspace.archivedAt) return;
teardownArchivedWorkspaceRuntime(workspaceId);
@@ -983,6 +991,8 @@ export async function createPaseoDaemon(
logger,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
getAutoArchivedChangeRequestUrl: async (workspaceId) =>
(await workspaceRegistry.get(workspaceId))?.autoArchivedChangeRequestUrl ?? null,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { afterEach, beforeEach, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { createNoopWorkspaceGitService } from "../../test-utils/workspace-git-service-stub.js";
import {
createNoGitWorkspaceRuntimeSnapshot,
createNoopWorkspaceGitService,
} from "../../test-utils/workspace-git-service-stub.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
@@ -32,6 +35,7 @@ let tmpDir: string;
let gitRoots: Set<string>;
let gitBranches: Map<string, string | null>;
let checkoutFailure: Error | null;
let restoredMergedChangeRequestUrl: string | null;
let workspaceRegistry: FileBackedWorkspaceRegistry;
let projectRegistry: FileBackedProjectRegistry;
let provisioning: WorkspaceProvisioningService;
@@ -39,6 +43,25 @@ let provisioning: WorkspaceProvisioningService;
function gitService() {
return createNoopWorkspaceGitService({
peekSnapshot: () => null,
getSnapshot: async (cwd: string) => {
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
if (!restoredMergedChangeRequestUrl) return snapshot;
return {
...snapshot,
forge: {
...snapshot.forge,
featuresEnabled: true,
pullRequest: {
url: restoredMergedChangeRequestUrl,
title: "Merged change request",
state: "merged",
baseRefName: "main",
headRefName: "workspace-archive-latch",
isMerged: true,
},
},
};
},
getCheckout: async (cwd: string) => {
if (checkoutFailure) throw checkoutFailure;
let worktreeRoot: string | null = null;
@@ -68,6 +91,7 @@ beforeEach(async () => {
gitRoots = new Set();
gitBranches = new Map();
checkoutFailure = null;
restoredMergedChangeRequestUrl = null;
workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
@@ -337,6 +361,73 @@ test("ensureWorkspaceRecordUnarchived restores the owning archived project with
expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull();
});
test("ensureWorkspaceRecordUnarchived preserves the consumed auto-archive change request", async () => {
const repo = path.join(tmpDir, "repo");
const changeRequestUrl = "https://github.com/getpaseo/paseo/pull/2714";
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT, {
autoArchivedChangeRequestUrl: changeRequestUrl,
});
const archivedWorkspace = await workspaceRegistry.get(created.workspaceId);
const unarchived = await provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!);
expect(unarchived).toMatchObject({
archivedAt: null,
autoArchivedChangeRequestUrl: changeRequestUrl,
});
expect(await workspaceRegistry.get(created.workspaceId)).toMatchObject({
archivedAt: null,
autoArchivedChangeRequestUrl: changeRequestUrl,
});
});
test("ensureWorkspaceRecordUnarchived acknowledges a merged change request for a legacy archive", async () => {
const repo = path.join(tmpDir, "repo");
const changeRequestUrl = "https://github.com/getpaseo/paseo/pull/2714";
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
const archivedWorkspace = await workspaceRegistry.get(created.workspaceId);
restoredMergedChangeRequestUrl = changeRequestUrl;
const unarchived = await provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!);
expect(unarchived).toMatchObject({
archivedAt: null,
autoArchivedChangeRequestUrl: changeRequestUrl,
});
expect(await workspaceRegistry.get(created.workspaceId)).toMatchObject({
archivedAt: null,
autoArchivedChangeRequestUrl: changeRequestUrl,
});
});
test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged change request", async () => {
const repo = path.join(tmpDir, "repo");
const previousChangeRequestUrl = "https://github.com/getpaseo/paseo/pull/2713";
const currentChangeRequestUrl = "https://github.com/getpaseo/paseo/pull/2714";
gitRoots.add(repo);
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT, {
autoArchivedChangeRequestUrl: previousChangeRequestUrl,
});
const archivedWorkspace = await workspaceRegistry.get(created.workspaceId);
restoredMergedChangeRequestUrl = currentChangeRequestUrl;
const unarchived = await provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!);
expect(unarchived).toMatchObject({
archivedAt: null,
autoArchivedChangeRequestUrl: currentChangeRequestUrl,
});
expect(await workspaceRegistry.get(created.workspaceId)).toMatchObject({
archivedAt: null,
autoArchivedChangeRequestUrl: currentChangeRequestUrl,
});
});
test("does not unarchive either record when checkout refresh fails", async () => {
const repo = path.join(tmpDir, "repo");
gitRoots.add(repo);
@@ -88,7 +88,7 @@ export function createWorkspaceProvisioningService(deps: {
serverId?: string;
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
logger: Logger;
}): WorkspaceProvisioningService {
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
@@ -323,6 +323,22 @@ export function createWorkspaceProvisioningService(deps: {
).workspaceId;
}
async function resolveRestoredAutoArchiveChangeRequestUrl(
workspace: PersistedWorkspaceRecord,
): Promise<string | null> {
if (!workspace.archivedAt) {
return workspace.autoArchivedChangeRequestUrl;
}
const snapshot = await workspaceGitService.getSnapshot(workspace.cwd, {
force: true,
includeForge: true,
reason: "workspace-restore-auto-archive-latch",
});
return snapshot.forge.pullRequest?.isMerged
? snapshot.forge.pullRequest.url
: workspace.autoArchivedChangeRequestUrl;
}
async function ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
@@ -333,6 +349,8 @@ export function createWorkspaceProvisioningService(deps: {
workspace.archivedAt || project.archivedAt
? await workspaceGitService.getCheckout(workspace.cwd)
: null;
const autoArchivedChangeRequestUrl =
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
let next: PersistedWorkspaceRecord | null = null;
if (workspace.archivedAt && checkout) {
const placementUpdate = reconcileWorkspacePlacement({
@@ -343,6 +361,7 @@ export function createWorkspaceProvisioningService(deps: {
next = {
...(placementUpdate?.workspace ?? workspace),
archivedAt: null,
autoArchivedChangeRequestUrl,
updatedAt: timestamp,
};
}
@@ -13,7 +13,11 @@ import {
WorktreeTeardownError,
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
import type {
PersistedWorkspaceRecord,
WorkspaceArchiveContext,
WorkspaceRegistry,
} from "./workspace-registry.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
export type ActiveWorkspaceRef = Pick<
@@ -526,6 +530,7 @@ export async function archivePersistedWorkspaceRecord(input: {
workspaceId: string;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
archivedAt?: string;
context?: WorkspaceArchiveContext;
}): Promise<PersistedWorkspaceRecord | null> {
const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId);
if (!existingWorkspace) {
@@ -537,7 +542,7 @@ export async function archivePersistedWorkspaceRecord(input: {
}
const archivedAt = input.archivedAt ?? new Date().toISOString();
await input.workspaceRegistry.archive(input.workspaceId, archivedAt);
await input.workspaceRegistry.archive(input.workspaceId, archivedAt, input.context);
return existingWorkspace;
}
@@ -630,6 +630,7 @@ describe("WorkspaceReconciliationService", () => {
createdAt: timestamp,
updatedAt: expect.any(String),
archivedAt: expect.any(String),
autoArchivedChangeRequestUrl: null,
});
expect(projects.get("p1")).toEqual(project);
});
@@ -420,6 +420,35 @@ describe("workspace registries", () => {
});
});
test("persists the consumed change request with the workspace archive", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: "workspace-auto-archive",
projectId: "project-one",
cwd: "/tmp/repo",
kind: "worktree",
displayName: "feature",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
await workspaceRegistry.archive("workspace-auto-archive", "2026-03-02T00:00:00.000Z", {
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
});
const reloaded = new FileBackedWorkspaceRegistry(
path.join(tmpDir, "projects", "workspaces.json"),
logger,
);
await reloaded.initialize();
expect(await reloaded.get("workspace-auto-archive")).toMatchObject({
archivedAt: "2026-03-02T00:00:00.000Z",
autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123",
});
});
test("composes concurrent workspace field updates without losing either change", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert(
@@ -80,6 +80,13 @@ const PersistedWorkspaceRecordSchema = z.object({
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
// COMPAT(autoArchivedChangeRequestUrl): added in v0.2.6, remove optional parsing after 2027-01-31.
// Records the merged change request whose automatic archive was consumed.
autoArchivedChangeRequestUrl: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
pinnedAt: z
.string()
.nullable()
@@ -101,6 +108,10 @@ export interface WorkspaceMutationContext {
expectsInitialAgent?: boolean;
}
export interface WorkspaceArchiveContext {
autoArchivedChangeRequestUrl?: string;
}
export interface ProjectMutation {
kind: "upsert" | "archive" | "remove";
projectId: string;
@@ -140,7 +151,11 @@ export interface WorkspaceRegistry {
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord | null>;
upsert(record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext): Promise<void>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
archive(
workspaceId: string,
archivedAt: string,
context?: WorkspaceArchiveContext,
): Promise<void>;
remove(workspaceId: string): Promise<void>;
/** Central lifecycle seam for daemon-global workspace observers. */
subscribeToMutations?(
@@ -471,8 +486,19 @@ export class FileBackedWorkspaceRegistry
});
}
override async archive(workspaceId: string, archivedAt: string): Promise<void> {
const workspace = await this.archiveIfPresent(workspaceId, archivedAt);
override async archive(
workspaceId: string,
archivedAt: string,
context?: WorkspaceArchiveContext,
): Promise<void> {
const workspace = await super.update(workspaceId, (existing) => ({
...existing,
updatedAt: archivedAt,
archivedAt,
...(context?.autoArchivedChangeRequestUrl
? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl }
: {}),
}));
if (!workspace) return;
await this.notifyMutation({ kind: "archive", workspaceId, workspace });
}
@@ -528,6 +554,7 @@ export function createPersistedWorkspaceRecord(input: {
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
autoArchivedChangeRequestUrl?: string | null;
pinnedAt?: string | null;
}): PersistedWorkspaceRecord {
return PersistedWorkspaceRecordSchema.parse({
@@ -539,6 +566,7 @@ export function createPersistedWorkspaceRecord(input: {
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
mainRepoRoot: input.mainRepoRoot ?? null,
archivedAt: input.archivedAt ?? null,
autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null,
pinnedAt: input.pinnedAt ?? null,
});
}