Restore an archived workspace when its agent is reopened

Reopening an archived agent previously dead-ended at "workspace not
found": the agent record was un-archived but its workspace stayed
archived and was never sent to the client.

The daemon now un-archives the owning workspace alongside the agent when
its backing directory still exists, and the app auto-fires the restore
ahead of the missing-workspace gate so the user lands back in the
workspace rather than a dead end.

Worktrees whose directory was deleted on archive are not yet restorable
(recreating them from their branch is a follow-up); the workspace record
now persists its base branch as groundwork for that.
This commit is contained in:
Mohamed Boudra
2026-06-18 20:44:56 +07:00
parent fe15b68e06
commit 31fcde2ea6
22 changed files with 778 additions and 17 deletions
@@ -65,6 +65,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(result.workspace.baseBranch).toBe("main");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(events).toEqual([
"project:remote:github.com/acme/repo",
@@ -425,6 +426,9 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
version: 1,
baseRefName: "dev",
});
// A checkout-branch worktree has no distinct base, so the workspace records a
// null baseBranch even though worktree.json's baseRefName is the branch itself.
expect(created.workspace.baseBranch).toBe(null);
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
@@ -21,6 +21,7 @@ import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
import {
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
normalizeBaseRefName,
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
} from "../utils/worktree-metadata.js";
@@ -71,6 +72,7 @@ export async function createPaseoWorktree(
projectId: input.projectId,
repoRoot: createdWorktree.repoRoot,
worktree: createdWorktree.worktree,
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
deps,
});
@@ -197,11 +199,25 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
});
}
// The base branch is normalized to match worktree.json's baseRefName (origin/
// stripped). checkout-branch worktrees have no distinct base, so they stay null.
function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null {
switch (intent.kind) {
case "branch-off":
return normalizeBaseRefName(intent.baseBranch);
case "checkout-github-pr":
return normalizeBaseRefName(intent.baseRefName);
case "checkout-branch":
return null;
}
}
async function upsertWorkspaceForWorktree(options: {
inputCwd: string;
projectId?: string;
repoRoot: string;
worktree: WorktreeConfig;
baseBranch?: string | null;
title?: string | null;
deps: Pick<
CreatePaseoWorktreeDeps,
@@ -244,6 +260,7 @@ async function upsertWorkspaceForWorktree(options: {
kind: "worktree",
displayName: options.worktree.branchName || normalizedCwd,
branch: options.worktree.branchName || null,
baseBranch: options.baseBranch ?? null,
title: options.title ?? null,
createdAt: now,
updatedAt: now,
+18
View File
@@ -3503,6 +3503,7 @@ 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) {
@@ -7066,6 +7067,23 @@ export class Session {
};
}
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> {
const record = await this.agentStorage.get(agentId);
if (!record?.workspaceId) {
return;
}
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
if (!directoryExists) {
return;
}
const workspace = await this.workspaceRegistry.get(record.workspaceId);
if (!workspace?.archivedAt) {
return;
}
await this.ensureWorkspaceRecordUnarchived(workspace);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
}
private async ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
@@ -83,6 +83,8 @@ interface SessionTestAccess {
};
agentManager: {
listAgents(): unknown[];
getAgent(agentId: string): unknown;
reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise<unknown>;
listImportableSessions(options?: unknown): Promise<unknown[]>;
importProviderSession(input: unknown): Promise<unknown>;
resumeAgentFromPersistence(
@@ -147,6 +149,9 @@ interface SessionTestAccess {
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
};
filesystem: {
isDirectory(cwd: string): Promise<boolean>;
};
}
interface ListFetchResult {
@@ -4040,6 +4045,193 @@ test("open_project_request unarchives an existing archived workspace and project
expect(response?.payload.workspace?.id).toBe(workspaceId);
});
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = path.resolve("/tmp/paseo-unit2-existing-dir");
session.filesystem.isDirectory = async () => true;
const workspaceId = "ws-repo-archived";
const agentId = "agent-archived";
projects.set(
cwd,
createPersistedProjectRecord({
projectId: cwd,
rootPath: cwd,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
const storedAgent: StoredAgentRecord = {
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
workspaceId,
archivedAt: "2026-03-10T00:00:00.000Z",
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {};
const managed = makeManagedAgent({
id: agentId,
cwd,
workspaceId,
lifecycle: "idle",
updatedAt: "2026-03-10T00:00:00.000Z",
});
session.agentManager.getAgent = () => managed;
session.interruptAgentIfRunning = async () => undefined;
session.agentManager.reloadAgentSession = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.forwardAgentUpdate = async () => undefined;
const unarchivedWorkspaceIds: string[][] = [];
const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session);
session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => {
unarchivedWorkspaceIds.push(ids);
return realEmit(ids, ...rest);
};
await session.handleMessage({
type: "refresh_agent_request",
agentId,
requestId: "req-refresh-unarchive",
});
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
expect(findByType(emitted, "rpc_error")).toBeUndefined();
});
test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = path.resolve("/tmp/paseo-missing-workspace-dir");
session.filesystem.isDirectory = async () => false;
const workspaceId = "ws-missing-dir";
const agentId = "agent-missing-dir";
projects.set(
cwd,
createPersistedProjectRecord({
projectId: cwd,
rootPath: cwd,
kind: "non_git",
displayName: "missing",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
displayName: "missing",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
const storedAgent: StoredAgentRecord = {
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
workspaceId,
archivedAt: "2026-03-10T00:00:00.000Z",
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
workspaces.set(record.workspaceId, record);
};
session.projectRegistry.list = async () => Array.from(projects.values());
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {};
const managed = makeManagedAgent({
id: agentId,
cwd,
workspaceId,
lifecycle: "idle",
updatedAt: "2026-03-10T00:00:00.000Z",
});
session.agentManager.getAgent = () => managed;
session.interruptAgentIfRunning = async () => undefined;
session.agentManager.reloadAgentSession = async () => managed;
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
session.agentManager.getTimeline = () => [];
session.forwardAgentUpdate = async () => undefined;
await session.handleMessage({
type: "refresh_agent_request",
agentId,
requestId: "req-refresh-missing-dir",
});
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -45,6 +45,14 @@ const PersistedWorkspaceRecordSchema = z.object({
.nullable()
.optional()
.transform((value) => value ?? null),
// The base branch the worktree was created from (normalized like worktree.json's
// baseRefName). Only worktree workspaces carry a base branch; checkout-branch
// worktrees and directory/local_checkout workspaces leave it null.
baseBranch: z
.string()
.nullable()
.optional()
.transform((value) => value ?? null),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
@@ -245,6 +253,7 @@ export function createPersistedWorkspaceRecord(input: {
displayName: string;
title?: string | null;
branch?: string | null;
baseBranch?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
@@ -253,6 +262,7 @@ export function createPersistedWorkspaceRecord(input: {
...input,
title: input.title ?? null,
branch: input.branch ?? null,
baseBranch: input.baseBranch ?? null,
archivedAt: input.archivedAt ?? null,
});
}