fix(app): require explicit workspace recovery

This commit is contained in:
Mohamed Boudra
2026-07-16 15:14:30 +02:00
parent b1e9ac0009
commit 05cb0f99e5
39 changed files with 1293 additions and 1129 deletions
+81 -29
View File
@@ -167,6 +167,10 @@ import {
createWorkspaceProvisioningService,
type WorkspaceProvisioningService,
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
import {
createWorkspaceRecoveryService,
type WorkspaceRecoveryService,
} from "./session/workspace-recovery/workspace-recovery-service.js";
import {
createAgentUpdatesService,
matchesAgentUpdatesFilter,
@@ -563,6 +567,7 @@ export class Session {
private readonly workspaceAutoName: WorkspaceAutoName;
private readonly gitMutation: GitMutationService;
private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly workspaceRecovery: WorkspaceRecoveryService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
@@ -697,6 +702,15 @@ export class Session {
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
});
this.workspaceRecovery = createWorkspaceRecoveryService({
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
getProject: (projectId) => this.projectRegistry.get(projectId),
isDirectory: (path) => this.filesystem.isDirectory(path),
recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace),
unarchiveWorkspace: async (workspace) => {
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
},
});
this.checkoutSession = new CheckoutSession({
host: {
emit: (msg) => this.emit(msg),
@@ -1389,6 +1403,7 @@ export class Session {
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceRecoveryMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg) ??
this.dispatchProviderMessage(msg) ??
@@ -1685,6 +1700,17 @@ export class Session {
}
}
private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "workspace.recovery.inspect.request":
return this.handleWorkspaceRecoveryInspectRequest(msg);
case "workspace.recovery.restore.request":
return this.handleWorkspaceRecoveryRestoreRequest(msg);
default:
return undefined;
}
}
private dispatchProviderMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "list_provider_models_request":
@@ -2400,6 +2426,54 @@ export class Session {
}
}
private async handleWorkspaceRecoveryInspectRequest(
request: Extract<SessionInboundMessage, { type: "workspace.recovery.inspect.request" }>,
): Promise<void> {
const state = await this.workspaceRecovery.inspect(request.workspaceId);
this.emit({
type: "workspace.recovery.inspect.response",
payload: {
requestId: request.requestId,
state,
},
});
}
private async handleWorkspaceRecoveryRestoreRequest(
request: Extract<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
): Promise<void> {
try {
await this.workspaceRecovery.restore(request.workspaceId);
await this.emitWorkspaceUpdatesForWorkspaceIds([request.workspaceId], {
skipReconcile: true,
});
this.emit({
type: "workspace.recovery.restore.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
accepted: true,
error: null,
},
});
} catch (error) {
const message = getErrorMessageOr(error, "Failed to recover workspace");
this.sessionLogger.warn(
{ err: error, workspaceId: request.workspaceId, requestId: request.requestId },
"session: workspace.recovery.restore.request rejected",
);
this.emit({
type: "workspace.recovery.restore.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
accepted: false,
error: message,
},
});
}
}
/**
* Handle text message to agent (with optional image attachments)
*/
@@ -2763,7 +2837,6 @@ export class Session {
try {
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
await this.unarchiveOwningWorkspaceForAgent(agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
@@ -3873,35 +3946,14 @@ export class Session {
};
}
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> {
const record = await this.agentStorage.get(agentId);
if (!record?.workspaceId) {
return;
private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void> {
const branch = workspace.branch;
if (!branch) {
throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
}
const workspace = await this.workspaceRegistry.get(record.workspaceId);
if (!workspace?.archivedAt) {
return;
}
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
if (!directoryExists) {
if (workspace.kind !== "worktree" || !workspace.branch) {
return;
}
// Recreate the worktree directory from its kept branch BEFORE clearing
// archivedAt — the reconciler re-archives workspaces whose directory is
// missing, so the record must point at a real directory first.
await this.recreateOwningWorktreeForRestore(workspace, workspace.branch);
}
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
}
private async recreateOwningWorktreeForRestore(
workspace: PersistedWorkspaceRecord,
branch: string,
): Promise<void> {
const project = await this.projectRegistry.get(workspace.projectId);
if (!project) {
throw new WorktreeRequestError({
@@ -32,13 +32,13 @@ import type {
AgentSessionConfig,
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
import { createWorktree, UnknownBranchError } from "../utils/worktree.js";
import { createWorktree } from "../utils/worktree.js";
import {
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
writePaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js";
import { WorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
@@ -128,10 +128,7 @@ interface SessionTestAccess {
agentUpdates: AgentUpdatesService;
workspaceUpdatesSubscription: unknown;
interruptAgentIfRunning(agentId: string): unknown;
recreateOwningWorktreeForRestore(
workspace: PersistedWorkspaceRecord,
branch: string,
): Promise<void>;
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
reconcileWorkspaceRecord(workspaceId: string): Promise<{
changed: boolean;
@@ -4422,7 +4419,7 @@ test("open_project_request recreates a missing project record when unarchiving i
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
});
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
test("refresh_agent_request leaves workspace archival independent when its directory exists", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
@@ -4514,13 +4511,13 @@ test("refresh_agent_request unarchives the owning workspace when its directory e
requestId: "req-refresh-unarchive",
});
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(unarchivedWorkspaceIds).toEqual([]);
expect(findByType(emitted, "rpc_error")).toBeUndefined();
});
test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => {
test("refresh_agent_request leaves workspace archival independent when its directory is missing", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
@@ -4609,7 +4606,7 @@ test("refresh_agent_request leaves the owning workspace archived when its direct
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
});
test("refresh_agent_request recreates a deleted worktree directory and unarchives the same workspace", async () => {
test("refresh_agent_request does not recreate or unarchive a deleted worktree", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
@@ -4675,13 +4672,6 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {};
const recreateCalls: string[] = [];
session.recreateOwningWorktreeForRestore = async (
workspace: PersistedWorkspaceRecord,
): Promise<void> => {
recreateCalls.push(workspace.workspaceId);
};
const managed = makeManagedAgent({
id: agentId,
cwd,
@@ -4709,14 +4699,13 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
requestId: "req-refresh-recreate-worktree",
});
expect(recreateCalls).toEqual([workspaceId]);
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
expect(unarchivedWorkspaceIds).toEqual([]);
expect(findByType(emitted, "rpc_error")).toBeUndefined();
});
test("refresh_agent_request leaves the worktree archived and surfaces a typed error when recreation fails", async () => {
test("refresh_agent_request does not inspect an archived worktree branch", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
@@ -4782,10 +4771,6 @@ test("refresh_agent_request leaves the worktree archived and surfaces a typed er
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {};
session.recreateOwningWorktreeForRestore = async (): Promise<void> => {
throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd }));
};
const managed = makeManagedAgent({
id: agentId,
cwd,
@@ -4807,9 +4792,7 @@ test("refresh_agent_request leaves the worktree archived and surfaces a typed er
});
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
const rpcError = findByType(emitted, "rpc_error");
expect(rpcError).toBeDefined();
expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch");
expect(findByType(emitted, "rpc_error")).toBeUndefined();
});
function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
@@ -4828,7 +4811,7 @@ function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
return { tempDir, repoDir };
}
test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => {
test("refresh_agent_request leaves a real deleted worktree for explicit workspace recovery", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
@@ -4937,22 +4920,15 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
});
expect(findByType(emitted, "rpc_error")).toBeUndefined();
expect(existsSync(worktreePath)).toBe(true);
const headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(headBranch).toBe(branch);
expect(existsSync(worktreePath)).toBe(false);
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
rmSync(tempDir, { recursive: true, force: true });
});
test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and leaves the workspace archived when the project root is missing", async () => {
test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
@@ -5005,9 +4981,9 @@ test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and l
session.filesystem.isDirectory = async (target: string) =>
existsSync(target) && statSync(target).isDirectory();
await expect(
session.recreateOwningWorktreeForRestore(workspaceRecord, branch),
).rejects.toBeInstanceOf(WorktreeRequestError);
await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
WorktreeRequestError,
);
// Guard fires before createWorktree, so archivedAt is untouched.
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
@@ -0,0 +1,131 @@
import { describe, expect, test } from "vitest";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "../../workspace-registry.js";
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
const NOW = "2026-07-11T10:12:30.752Z";
function createProject(): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: "/repo",
rootPath: "/repo",
kind: "git",
displayName: "repo",
createdAt: NOW,
updatedAt: NOW,
});
}
function createWorkspace(
overrides: Partial<PersistedWorkspaceRecord> = {},
): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: "wks_15a1b5630ebaab33",
projectId: "/repo",
cwd: "/worktrees/trigger-1525443412986298439",
kind: "worktree",
displayName: "diagnose-repro-tdd",
title: "Codex TDD reproduction",
branch: "diagnose-repro-tdd",
createdAt: NOW,
updatedAt: NOW,
archivedAt: NOW,
...overrides,
});
}
function createHarness(input?: {
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
directories?: string[];
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}) {
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
const project = input?.project === undefined ? createProject() : input.project;
const directories = new Set(input?.directories ?? ["/repo"]);
const unarchived: string[] = [];
const recreated: string[] = [];
const service = createWorkspaceRecoveryService({
getWorkspace: async (workspaceId) =>
workspace?.workspaceId === workspaceId ? workspace : null,
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
isDirectory: async (path) => directories.has(path),
recreateWorktree: async (record) => {
recreated.push(record.workspaceId);
await input?.recreate?.(record);
},
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
return { service, recreated, unarchived };
}
describe("workspace recovery", () => {
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
const { service, recreated, unarchived } = createHarness();
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
});
expect(recreated).toEqual([]);
expect(unarchived).toEqual([]);
});
test("describes an archived workspace whose directory remains as unarchivable", async () => {
const workspace = createWorkspace({ kind: "directory", branch: null });
const { service } = createHarness({
workspace,
directories: ["/repo", workspace.cwd],
});
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
kind: "recoverable",
action: "unarchive",
});
});
test("does not offer recovery for a missing non-worktree directory", async () => {
const workspace = createWorkspace({ kind: "directory", branch: null });
const { service } = createHarness({ workspace });
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
kind: "unavailable",
workspaceId: workspace.workspaceId,
reason: "workspace_directory_missing",
message: "The archived workspace directory no longer exists and cannot be recreated.",
});
});
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
let attempts = 0;
const { service, recreated, unarchived } = createHarness({
recreate: async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("git branch diagnose-repro-tdd is unavailable");
}
},
});
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
"git branch diagnose-repro-tdd is unavailable",
);
expect(unarchived).toEqual([]);
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
workspaceId: "wks_15a1b5630ebaab33",
action: "restore",
});
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
});
});
@@ -0,0 +1,135 @@
import {
resolveWorkspaceDisplayName,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "../../workspace-registry.js";
export type WorkspaceRecoveryAction = "unarchive" | "restore";
export type WorkspaceRecoveryState =
| {
kind: "recoverable";
workspaceId: string;
workspaceName: string;
action: WorkspaceRecoveryAction;
branch: string | null;
}
| {
kind: "unavailable";
workspaceId: string;
reason:
| "workspace_not_found"
| "workspace_not_archived"
| "project_not_found"
| "project_directory_missing"
| "workspace_directory_missing"
| "worktree_branch_missing";
message: string;
};
export interface WorkspaceRecoveryService {
inspect(workspaceId: string): Promise<WorkspaceRecoveryState>;
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
}
export function createWorkspaceRecoveryService(deps: {
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
isDirectory: (path: string) => Promise<boolean>;
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}): WorkspaceRecoveryService {
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace) {
return {
kind: "unavailable",
workspaceId,
reason: "workspace_not_found",
message: "This workspace is no longer known to the host.",
};
}
if (!workspace.archivedAt) {
return {
kind: "unavailable",
workspaceId,
reason: "workspace_not_archived",
message: "This workspace is not archived, but it is unavailable from the host.",
};
}
const project = await deps.getProject(workspace.projectId);
if (!project) {
return {
kind: "unavailable",
workspaceId,
reason: "project_not_found",
message: "The project for this archived workspace no longer exists.",
};
}
if (await deps.isDirectory(workspace.cwd)) {
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "unarchive",
branch: workspace.branch,
};
}
if (workspace.kind !== "worktree") {
return {
kind: "unavailable",
workspaceId,
reason: "workspace_directory_missing",
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.",
};
}
if (!(await deps.isDirectory(project.rootPath))) {
return {
kind: "unavailable",
workspaceId,
reason: "project_directory_missing",
message: "The project directory needed to restore this worktree no longer exists.",
};
}
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "restore",
branch: workspace.branch,
};
}
async function restore(
workspaceId: string,
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
const state = await inspect(workspaceId);
if (state.kind === "unavailable") {
throw new Error(state.message);
}
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace?.archivedAt) {
throw new Error("The archived workspace changed before it could be recovered.");
}
if (state.action === "restore") {
await deps.recreateWorktree(workspace);
}
await deps.unarchiveWorkspace(workspace);
return { workspaceId, action: state.action };
}
return { inspect, restore };
}
@@ -1228,8 +1228,8 @@ export class VoiceAssistantWebSocketServer {
projectRemove: true,
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectAdd: true,
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.