Improve the archived workspace restore flow (#2002)

* fix(app): restore missing workspaces from History

Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.

* fix(app): wait for workspace hydration before restore

* fix(app): preserve History agent unarchive

* fix(app): reopen archived History agents outside active directory

History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.

* fix(app): ignore stale History archive state for active agents

A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.

* fix(app): reopen every selected archived History agent

History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.

* fix(app): require explicit workspace recovery

* fix: preserve workspace recovery contracts

* fix: preserve workspace recovery compatibility

* fix: preserve recovered workspace session state

* fix: preserve terminal navigation timing

* fix(server): preserve successful workspace recovery

* fix: preserve workspace recovery intent
This commit is contained in:
Mohamed Boudra
2026-07-16 17:22:37 +02:00
committed by GitHub
parent d42ab91971
commit 5da6548aff
47 changed files with 1769 additions and 823 deletions
@@ -1008,6 +1008,8 @@ test("receives server_info on websocket connect", async () => {
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
expect(serverInfo?.desktopManaged).toBe(false);
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
expect(serverInfo?.features?.worktreeRestore).toBe(true);
expect(serverInfo?.features?.workspaceRecovery).toBe(true);
await client.close();
}, 15000);
+150 -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,
@@ -236,6 +240,7 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis
// the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY = "0.1.105";
function errorToFriendlyMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
@@ -315,6 +320,12 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
}
function clientUsesLegacyWorkspaceRestore(appVersion: string | null): boolean {
return (
appVersion !== null && !isAppVersionAtLeast(appVersion, MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY)
);
}
type DeleteFencedAgentStorage = AgentStorage & {
beginDelete(agentId: string): void;
};
@@ -420,6 +431,7 @@ export interface SessionOptions {
onBinaryMessage?: (frame: Uint8Array) => void;
getTransportBufferedAmount?: () => number | null;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
logger: pino.Logger;
downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore;
@@ -548,6 +560,9 @@ export class Session {
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly getTransportBufferedAmount: () => number | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly onWorkspaceRecovered:
| ((workspace: PersistedWorkspaceRecord) => Promise<void>)
| null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
@@ -563,6 +578,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;
@@ -611,6 +627,7 @@ export class Session {
onBinaryMessage,
getTransportBufferedAmount,
onLifecycleIntent,
onWorkspaceRecovered,
logger,
downloadTokenStore,
pushTokenStore,
@@ -660,6 +677,7 @@ export class Session {
this.onBinaryMessage = onBinaryMessage ?? null;
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot;
@@ -697,6 +715,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),
@@ -974,6 +1001,27 @@ export class Session {
await this.workspaceGitObserver.warmGitData(workspace);
}
async refreshRecoveredWorkspaceForExternalMutation(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
try {
await this.workspaceGitObserver.warmGitData(workspace);
} catch (error) {
this.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm git observer after workspace recovery",
);
try {
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
} catch (emitError) {
this.sessionLogger.warn(
{ err: emitError, workspaceId: workspace.workspaceId },
"Failed to emit workspace update after recovery",
);
}
}
}
/**
* Get the client's current activity state
*/
@@ -1389,6 +1437,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 +1734,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 +2460,51 @@ 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.restoreWorkspaceAndEmit(request.workspaceId);
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)
*/
@@ -2762,8 +2867,8 @@ export class Session {
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`);
try {
await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId);
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
await this.unarchiveOwningWorkspaceForAgent(agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
@@ -3873,35 +3978,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({
@@ -3953,6 +4037,43 @@ export class Session {
}
}
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
await this.workspaceRecovery.restore(workspaceId);
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
}
if (this.onWorkspaceRecovered) {
try {
await this.onWorkspaceRecovered(workspace);
return;
} catch (error) {
this.sessionLogger.warn(
{ err: error, workspaceId },
"Failed to publish workspace recovery to active sessions",
);
}
}
await this.refreshRecoveredWorkspaceForExternalMutation(workspace);
}
private async restoreOwningWorkspaceForLegacyAgentRefresh(agentId: string): Promise<void> {
// COMPAT(worktreeRestore): clients older than v0.1.105 used refresh_agent_request
// as their explicit recovery RPC. Remove after 2027-01-11.
if (!clientUsesLegacyWorkspaceRestore(this.appVersion)) {
return;
}
const record = await this.agentStorage.get(agentId);
if (!record?.workspaceId) {
return;
}
const recovery = await this.workspaceRecovery.inspect(record.workspaceId);
if (recovery.kind !== "recoverable") {
return;
}
await this.restoreWorkspaceAndEmit(record.workspaceId);
}
private async createPaseoWorktree(
input: CreatePaseoWorktreeInput,
options?: {
@@ -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;
@@ -530,6 +527,7 @@ function createSessionForWorkspaceTests(
options: {
appVersion?: string | null;
onMessage?: (message: SessionOutboundMessage) => void;
onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"];
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
terminalManager?: TerminalManager | null;
projectRegistry?: SessionOptions["projectRegistry"];
@@ -616,6 +614,7 @@ function createSessionForWorkspaceTests(
clientId: "test-client",
appVersion: options.appVersion ?? null,
onMessage: options.onMessage ?? vi.fn(),
onWorkspaceRecovered: options.onWorkspaceRecovered,
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
@@ -4422,7 +4421,84 @@ 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("workspace recovery stays accepted when git observer warming fails", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaceGitService = createNoopWorkspaceGitService({
registerWorkspace: () => {
throw new Error("git watcher unavailable");
},
});
const session = createSessionForWorkspaceTests({
appVersion: "0.1.105",
workspaceGitService,
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const archivedAt = "2026-03-10T00:00:00.000Z";
let project = createPersistedProjectRecord({
projectId: REPO_CWD,
rootPath: REPO_CWD,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: archivedAt,
archivedAt,
});
let workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-recovery-warm-failure",
projectId: project.projectId,
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: archivedAt,
archivedAt,
});
session.filesystem.isDirectory = async () => true;
session.projectRegistry.get = async (projectId: string) =>
projectId === project.projectId ? project : null;
session.projectRegistry.list = async () => [project];
session.projectRegistry.upsert = async (record: PersistedProjectRecord) => {
project = record;
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaceId === workspace.workspaceId ? workspace : null;
session.workspaceRegistry.list = async () => [workspace];
session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => {
workspace = record;
};
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-recovery-warm-failure",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () => new Set();
session.listAgentPayloads = async () => [];
await session.handleMessage({
type: "workspace.recovery.restore.request",
requestId: "req-recovery-warm-failure",
workspaceId: workspace.workspaceId,
});
expect(workspace.archivedAt).toBeNull();
expect(findByType(emitted, "workspace.recovery.restore.response")?.payload).toEqual({
requestId: "req-recovery-warm-failure",
workspaceId: workspace.workspaceId,
accepted: true,
error: null,
});
expect(findByType(emitted, "workspace_update")?.payload).toMatchObject({
kind: "upsert",
workspace: { id: workspace.workspaceId },
});
});
test("refresh_agent_request leaves workspace archival independent when its directory exists", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
@@ -4514,13 +4590,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,9 +4685,10 @@ 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({
appVersion: "0.1.105",
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
@@ -4675,13 +4752,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 +4779,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 +4851,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 +4872,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 +4891,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("legacy refresh_agent_request restores a real deleted worktree", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
@@ -4851,6 +4914,7 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
appVersion: "0.1.104",
paseoHome,
worktreesRoot,
onMessage: (message) => {
@@ -4938,13 +5002,6 @@ 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(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
@@ -4952,7 +5009,7 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
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 +5062,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 };
}
+10 -1
View File
@@ -1009,6 +1009,13 @@ export class VoiceAssistantWebSocketServer {
onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent);
},
onWorkspaceRecovered: async (workspace) => {
await Promise.all(
this.listActiveSessions().map((activeSession) =>
activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace),
),
);
},
logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore,
@@ -1229,8 +1236,10 @@ 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
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
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.