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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user