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