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
@@ -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,
});
}