fix: restore archived workspaces without recorded branches (#5694)

Create a fresh branch from the recorded base when available, or the repository default, so archived agents remain accessible. Cover the server recovery choices and the browser restore-to-agent flow.
This commit is contained in:
Mohamed Boudra
2026-09-29 17:25:21 +01:00
committed by GitHub
parent a8f5d969b8
commit 4b45bc9fc1
15 changed files with 246 additions and 37 deletions
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
restoreArchivedWorkspace,
@@ -354,6 +355,88 @@ test.describe("Worktree restore", () => {
.toBeNull();
});
test("restores a branchless workspace so its archived agent can be used again", async ({
page,
}) => {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const backingWorktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `branchless-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(backingWorktree.projectKey);
createdWorktreeDirectories.add(backingWorktree.workspaceDirectory);
execFileSync("git", ["switch", "--detach", "main"], {
cwd: backingWorktree.workspaceDirectory,
stdio: "pipe",
});
const created = await worktreeClient.createWorkspace({
source: { kind: "directory", path: backingWorktree.workspaceDirectory },
title: "Sample workspace",
});
if (!created.workspace) {
throw new Error(created.error ?? "Could not create the branchless workspace");
}
const workspaceId = created.workspace.id;
const agent = await createIdleAgent(client, {
cwd: backingWorktree.workspaceDirectory,
workspaceId,
title: "Archived agent",
});
await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, {
scope: "worktree",
});
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull();
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible();
await expect(
page.getByText(
"Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.",
{ exact: true },
),
).toBeVisible();
await captureBranchlessRestoreStep(page, "01-restore-workspace.png");
await page.getByRole("button", { name: "Restore", exact: true }).click();
await expect
.poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({
timeout: 30_000,
});
await captureBranchlessRestoreStep(page, "02-unarchive-agent.png");
await page.getByRole("button", { name: "Unarchive", exact: true }).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull();
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable();
await captureBranchlessRestoreStep(page, "03-agent-ready.png");
const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim();
const defaultHead = execFileSync("git", ["rev-parse", "main"], {
cwd: tempRepo.path,
encoding: "utf8",
}).trim();
expect(restoredHead).toBe(defaultHead);
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: backingWorktree.workspaceDirectory,
encoding: "utf8",
}).trim(),
).toMatch(/^restored\//);
});
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
@@ -422,6 +505,12 @@ test.describe("Worktree restore", () => {
});
});
async function captureBranchlessRestoreStep(page: Page, filename: string): Promise<void> {
const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence");
mkdirSync(screenshotDirectory, { recursive: true });
await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
}
function commitRestorableChange(cwd: string): void {
writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n");
execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" });
+3 -1
View File
@@ -439,7 +439,9 @@ export const ar: TranslationResources = {
recovery: {
archivedTitle: "مساحة العمل مؤرشفة",
restoreDescription:
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
"استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.",
restoreWithoutBranchDescription:
"استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.",
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
restoreAction: "استعادة",
unarchiveAction: "إلغاء الأرشفة",
+3 -1
View File
@@ -436,7 +436,9 @@ export const en = {
recovery: {
archivedTitle: "Workspace archived",
restoreDescription:
"{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.",
"Restore {{workspaceName}} to return to its agents. Its worktree will use branch {{branch}}.",
restoreWithoutBranchDescription:
"Restore {{workspaceName}} to return to its agents. A new branch will start from the saved base or the repository default.",
unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.",
restoreAction: "Restore",
unarchiveAction: "Unarchive",
+3 -1
View File
@@ -444,7 +444,9 @@ export const es: TranslationResources = {
recovery: {
archivedTitle: "Espacio de trabajo archivado",
restoreDescription:
"{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.",
"Restaura {{workspaceName}} para volver a sus agentes. Su worktree usará la rama {{branch}}.",
restoreWithoutBranchDescription:
"Restaura {{workspaceName}} para volver a sus agentes. Se creará una rama nueva desde la base guardada o la rama predeterminada del repositorio.",
unarchiveDescription:
"{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.",
restoreAction: "Restaurar",
+3 -1
View File
@@ -445,7 +445,9 @@ export const fr: TranslationResources = {
recovery: {
archivedTitle: "Espace de travail archivé",
restoreDescription:
"{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.",
"Restaurez {{workspaceName}} pour retrouver ses agents. Son worktree utilisera la branche {{branch}}.",
restoreWithoutBranchDescription:
"Restaurez {{workspaceName}} pour retrouver ses agents. Une nouvelle branche partira de la base enregistrée ou de la branche par défaut du dépôt.",
unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.",
restoreAction: "Restaurer",
unarchiveAction: "Désarchiver",
+3 -1
View File
@@ -444,7 +444,9 @@ export const ja: TranslationResources = {
recovery: {
archivedTitle: "ワークスペースはアーカイブ済みです",
restoreDescription:
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
"{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。",
restoreWithoutBranchDescription:
"{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。",
unarchiveDescription:
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
restoreAction: "復元",
+3 -1
View File
@@ -440,7 +440,9 @@ export const ko: TranslationResources = {
recovery: {
archivedTitle: "워크스페이스가 보관되었습니다",
restoreDescription:
"{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.",
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.",
restoreWithoutBranchDescription:
"{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.",
unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.",
restoreAction: "복원",
unarchiveAction: "보관 취소",
+3 -1
View File
@@ -443,7 +443,9 @@ export const ptBR: TranslationResources = {
recovery: {
archivedTitle: "Workspace arquivado",
restoreDescription:
"{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.",
"Restaure {{workspaceName}} para voltar aos seus agentes. A worktree usará a branch {{branch}}.",
restoreWithoutBranchDescription:
"Restaure {{workspaceName}} para voltar aos seus agentes. Uma nova branch partirá da base salva ou da branch padrão do repositório.",
unarchiveDescription:
"{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.",
restoreAction: "Restaurar",
+3 -1
View File
@@ -443,7 +443,9 @@ export const ru: TranslationResources = {
recovery: {
archivedTitle: "Рабочая область в архиве",
restoreDescription:
"Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.",
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.",
restoreWithoutBranchDescription:
"Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.",
unarchiveDescription:
"Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.",
restoreAction: "Восстановить",
+3 -1
View File
@@ -439,7 +439,9 @@ export const zhCN: TranslationResources = {
recovery: {
archivedTitle: "Workspace 已归档",
restoreDescription:
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
"恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。",
restoreWithoutBranchDescription:
"恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。",
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
restoreAction: "恢复",
unarchiveAction: "取消归档",
@@ -126,15 +126,21 @@ function ArchivedWorkspaceRecovery({
if (recovery.phase === "failed") {
actionLabel = t("common.actions.retry");
}
const description =
recovery.recovery.action === "restore"
? t("workspace.route.recovery.restoreDescription", {
workspaceName: recovery.recovery.workspaceName,
branch: recovery.recovery.branch,
})
: t("workspace.route.recovery.unarchiveDescription", {
workspaceName: recovery.recovery.workspaceName,
});
let description: string;
if (recovery.recovery.action === "restore" && recovery.recovery.branch) {
description = t("workspace.route.recovery.restoreDescription", {
workspaceName: recovery.recovery.workspaceName,
branch: recovery.recovery.branch,
});
} else if (recovery.recovery.action === "restore") {
description = t("workspace.route.recovery.restoreWithoutBranchDescription", {
workspaceName: recovery.recovery.workspaceName,
});
} else {
description = t("workspace.route.recovery.unarchiveDescription", {
workspaceName: recovery.recovery.workspaceName,
});
}
return (
<View style={styles.emptyState}>
@@ -1,7 +1,10 @@
import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js";
import type { WorktreeSource } from "../utils/worktree.js";
export type WorktreeCreationIntent = Exclude<WorktreeSource, { kind: "restore" }>;
export type WorktreeCreationIntent = Exclude<
WorktreeSource,
{ kind: "restore" | "restore-from-base" }
>;
export interface ResolveWorktreeCreationIntentInput {
worktreeSlug?: string;
@@ -357,6 +357,67 @@ test("preserves ordinary checkout behavior when no separate base was recorded",
).toMatchObject({ currentBranch: "feature", baseRef: "feature" });
});
test("restores a branchless workspace from its recorded base so work can continue", async () => {
const fixture = await createBaseRecoveryFixture("refs/heads/release");
execFileSync("git", ["switch", "-c", "release"], { cwd: fixture.repoDir, stdio: "pipe" });
writeFileSync(join(fixture.repoDir, "release.txt"), "release\n");
execFileSync("git", ["add", "release.txt"], { cwd: fixture.repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "release"], { cwd: fixture.repoDir, stdio: "pipe" });
execFileSync("git", ["switch", "main"], { cwd: fixture.repoDir, stdio: "pipe" });
await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({
...workspace,
branch: null,
}));
await fixture.archiveAndRemove();
await expect(fixture.service.inspect(fixture.workspace.workspaceId)).resolves.toMatchObject({
kind: "recoverable",
action: "restore",
branch: null,
});
await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({
workspaceId: fixture.workspace.workspaceId,
action: "restore",
});
const checkout = await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome });
expect(checkout.currentBranch).toBeTruthy();
expect(checkout.currentBranch).not.toBe("feature");
expect(
execFileSync("git", ["rev-parse", "HEAD"], {
cwd: fixture.workspace.cwd,
encoding: "utf8",
}).trim(),
).toBe(
execFileSync("git", ["rev-parse", "release"], {
cwd: fixture.repoDir,
encoding: "utf8",
}).trim(),
);
});
test("restores a branchless workspace from the repository default when its base is missing", async () => {
const fixture = await createBaseRecoveryFixture("refs/heads/deleted-base");
await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({
...workspace,
branch: null,
}));
await fixture.archiveAndRemove();
await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({
workspaceId: fixture.workspace.workspaceId,
action: "restore",
});
expect(
execFileSync("git", ["rev-parse", "HEAD"], {
cwd: fixture.workspace.cwd,
encoding: "utf8",
}).trim(),
).toBe(
execFileSync("git", ["rev-parse", "main"], { cwd: fixture.repoDir, encoding: "utf8" }).trim(),
);
});
test("restores the branch HEAD with a base name when the exact base is missing", async () => {
const fixture = await createBaseRecoveryFixture("refs/remotes/upstream/main");
const headBeforeArchive = execFileSync("git", ["rev-parse", "HEAD"], {
@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";
import { basename } from "node:path";
import { resolveRepositoryDefaultBranch } from "../../../utils/checkout-git.js";
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
import { runGitCommand } from "../../../utils/run-git-command.js";
import {
@@ -7,6 +9,7 @@ import {
isPaseoOwnedWorktreeCwd,
mapWorkspaceCwdToWorktree,
rollbackCreatedPaseoWorktree,
type WorktreeSource,
} from "../../../utils/worktree.js";
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
import {
@@ -109,15 +112,6 @@ export function createWorkspaceRecoveryService(deps: {
message: "The archived workspace directory no longer exists and cannot be recreated.",
};
}
if (!workspace.branch) {
return {
kind: "unavailable",
workspaceId,
reason: "worktree_branch_missing",
message: "The archived worktree has no branch recorded, so it cannot be restored.",
};
}
// COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110
// lack placement ownership; remove the project-root fallback after 2027-01-17.
const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath;
@@ -157,13 +151,13 @@ export function createWorkspaceRecoveryService(deps: {
workspace: PersistedWorkspaceRecord,
sourceRepoRoot: string,
): Promise<void> {
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
const source: WorktreeSource = workspace.branch
? { kind: "restore", branchName: workspace.branch, baseRef: workspace.baseBranch }
: {
kind: "restore-from-base",
branchName: `restored/${randomUUID().slice(0, 8)}`,
baseRef: await resolveRecoveryBase(sourceRepoRoot, workspace.baseBranch),
};
try {
await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 });
@@ -189,7 +183,7 @@ export function createWorkspaceRecoveryService(deps: {
const result = await createWorktree({
cwd: sourceRepoRoot,
worktreeSlug: basename(previousWorktreePath),
source: { kind: "restore", branchName: branch, baseRef: workspace.baseBranch },
source,
runSetup: false,
paseoHome: deps.paseoHome,
worktreesRoot: deps.worktreesRoot,
@@ -234,6 +228,26 @@ export function createWorkspaceRecoveryService(deps: {
return { inspect, restore };
}
async function resolveRecoveryBase(repoRoot: string, recordedBase: string | null): Promise<string> {
if (recordedBase) {
try {
await runGitCommand(["rev-parse", "--verify", `${recordedBase}^{commit}`], { cwd: repoRoot });
return recordedBase;
} catch {
// The saved base was removed; use the repository default for continued work.
}
}
const defaultBranch = await resolveRepositoryDefaultBranch(repoRoot);
if (!defaultBranch) {
throw new WorktreeRequestError({
code: "unknown",
message:
"The saved base is unavailable and the repository has no default branch to restore from.",
});
}
return defaultBranch;
}
function createRecoveryPlan(
input:
| { action: "unarchive"; workspace: PersistedWorkspaceRecord }
+18
View File
@@ -186,6 +186,7 @@ export type WorktreeSource =
| { kind: "branch-off"; baseBranch: string; branchName: string }
| { kind: "checkout-branch"; branchName: string }
| { kind: "restore"; branchName: string; baseRef: string | null }
| { kind: "restore-from-base"; baseRef: string; branchName: string }
| {
kind: "checkout-change-request";
forge: string;
@@ -1341,6 +1342,21 @@ async function resolveRestoredWorktreeSourcePlan(
};
}
async function resolveRestoredWorktreeFromBasePlan(
cwd: string,
source: Extract<WorktreeSource, { kind: "restore-from-base" }>,
): Promise<WorktreeSourcePlan> {
await validateGitBranchName(cwd, source.branchName);
const baseRef = await resolveBaseBranchForWorktree(cwd, source.baseRef);
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
return {
branchName,
metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef),
metadataBaseRef: baseRef,
addArguments: ["-b", branchName, "--no-track", baseRef],
};
}
async function resolveBranchOffWorktreeSourcePlan(
cwd: string,
source: Extract<WorktreeSource, { kind: "branch-off" }>,
@@ -1377,6 +1393,8 @@ async function resolveWorktreeSourcePlan({
return resolveBranchOffWorktreeSourcePlan(cwd, source, desiredSlug);
case "restore":
return resolveRestoredWorktreeSourcePlan(cwd, source);
case "restore-from-base":
return resolveRestoredWorktreeFromBasePlan(cwd, source);
case "checkout-branch": {
await validateGitBranchName(cwd, source.branchName);
await ensureLocalBranch(cwd, source.branchName);