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
+6 -1
View File
@@ -174,11 +174,16 @@ export function parseWorkspaceOpenIntent(
export function parseHostWorkspaceOpenIntentFromPathname(
pathname: string,
): WorkspaceOpenIntent | null {
return parseWorkspaceOpenIntent(getHostWorkspaceOpenParamFromPathname(pathname));
}
export function getHostWorkspaceOpenParamFromPathname(pathname: string): string | null {
const search = extractSearch(pathname);
if (!search) {
return null;
}
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
const open = new URLSearchParams(search).get("open");
return parseWorkspaceOpenIntent(open) ? open : null;
}
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
@@ -1,62 +1,10 @@
import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
export type { NavigateToAgentInput } from "./resolve";
// Clears the transient restoring state if the daemon resolves refreshAgent without
// re-emitting a workspace_update (the directory-gone case), so the gate never spins
// forever. Recreating a worktree can require a git fetch, so the budget is generous
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
const RESTORE_TIMEOUT_MS = 30000;
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
const client = snapshot?.client ?? null;
if (!client || !isHostRuntimeConnected(snapshot)) {
return;
}
const store = useSessionStore.getState();
const session = store.sessions[serverId];
// Self-gate: only an archived agent whose workspace is absent needs restoring.
// A still-present workspace or an in-flight restore is a no-op; fire-once is
// derived from store state.
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
if (!agent?.archivedAt) {
return;
}
if (session?.workspaces.has(workspaceId)) {
return;
}
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
return;
}
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
// Single capability read for restore. An old daemon recreates nothing on
// refresh_agent, so a gone directory would spin then flash a misleading
// "couldn't restore". Surface an explicit "update your host" state instead.
if (session?.serverInfo?.features?.worktreeRestore !== true) {
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
return;
}
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
setTimeout(
() => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
RESTORE_TIMEOUT_MS,
);
client
.refreshAgent(agentId)
.catch(() =>
useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
);
}
export function navigateToAgent(input: NavigateToAgentInput): string {
return resolveNavigateToAgent(input, {
readAgentNavTarget: ({ serverId, agentId }) => {
@@ -70,8 +18,5 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
router.navigate(route as Href);
},
navigateToWorkspace,
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
restoreArchivedWorkspace(serverId, agentId, workspaceId);
},
});
}
@@ -16,25 +16,16 @@ interface RecordedHostNav {
interface RecordedTabNav extends NavigateToWorkspaceInput {}
interface RecordedRestore {
serverId: string;
agentId: string;
workspaceId: string;
}
function createFakeNavigators(target: AgentNavTarget): {
deps: NavigateToAgentDeps;
hostNavigations: RecordedHostNav[];
tabNavigations: RecordedTabNav[];
restores: RecordedRestore[];
} {
const hostNavigations: RecordedHostNav[] = [];
const tabNavigations: RecordedTabNav[] = [];
const restores: RecordedRestore[] = [];
return {
hostNavigations,
tabNavigations,
restores,
deps: {
readAgentNavTarget: () => target,
navigateToHostAgent: (route) => {
@@ -44,9 +35,6 @@ function createFakeNavigators(target: AgentNavTarget): {
tabNavigations.push(input);
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
},
restoreArchivedWorkspace: (input) => {
restores.push(input);
},
},
};
}
@@ -74,19 +62,6 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("delegates a restore attempt whenever a workspaceId resolves", () => {
const { deps, restores, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
expect(restores).toEqual([
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
]);
expect(tabNavigations).toHaveLength(1);
});
it("uses the input workspaceId without reading the nav target", () => {
const readTargets: { serverId: string; agentId: string }[] = [];
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
@@ -111,17 +86,6 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("does not trigger a restore when no workspaceId resolves", () => {
const { deps, restores, hostNavigations } = createFakeNavigators({
agentWorkspaceId: null,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
expect(restores).toEqual([]);
expect(hostNavigations).toHaveLength(1);
});
it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: null,
@@ -19,11 +19,6 @@ export interface NavigateToAgentDeps {
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
navigateToHostAgent: (route: string) => void;
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
restoreArchivedWorkspace: (input: {
serverId: string;
agentId: string;
workspaceId: string;
}) => void;
}
export function resolveNavigateToAgent(
@@ -41,14 +36,6 @@ export function resolveNavigateToAgent(
return route;
}
// Restore self-gates on the agent being archived with its workspace absent, so
// ordinary navigations are a cheap no-op.
deps.restoreArchivedWorkspace({
serverId: input.serverId,
agentId: input.agentId,
workspaceId,
});
return deps.navigateToWorkspace({
serverId: input.serverId,
workspaceId,
@@ -1,206 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
const refreshAgent = vi.fn<(agentId: string) => Promise<unknown>>();
let connected = true;
vi.mock("expo-router", () => ({
router: { navigate: vi.fn() },
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: vi.fn(() => ""),
}));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => ({
getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
}),
isHostRuntimeConnected: () => connected,
}));
import { useSessionStore, type Agent } from "@/stores/session-store";
import { navigateToAgent } from "./index";
const SERVER_ID = "server-1";
const AGENT_ID = "agent-1";
const WORKSPACE_ID = "workspace-1";
function agent(archivedAt: Date | null): Agent {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
return {
serverId: SERVER_ID,
id: AGENT_ID,
provider: "codex",
status: "idle",
createdAt,
updatedAt: createdAt,
lastUserMessageAt: null,
lastActivityAt: createdAt,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo",
workspaceId: WORKSPACE_ID,
model: null,
archivedAt,
parentAgentId: null,
labels: {},
};
}
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
return (
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
);
}
function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
store.updateSessionServerInfo(SERVER_ID, {
serverId: SERVER_ID,
hostname: "host",
version: "0.1.98",
features: { worktreeRestore: options?.worktreeRestore ?? true },
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
return next;
});
}
describe("restoreArchivedWorkspace via navigateToAgent", () => {
beforeEach(() => {
vi.useFakeTimers();
refreshAgent.mockReset();
connected = true;
seedArchivedAgent();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
useSessionStore.getState().clearSession(SERVER_ID);
});
function trigger(): void {
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
}
it("calls refreshAgent once and marks the workspace restoring", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).toHaveBeenCalledTimes(1);
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
expect(status()).toBe("restoring");
});
it("does not re-fire while a restore for the same workspace is in flight", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
trigger();
trigger();
expect(refreshAgent).toHaveBeenCalledTimes(1);
});
it("does not fire for a non-archived agent", () => {
const store = useSessionStore.getState();
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, agent(null));
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("does not fire while disconnected", () => {
connected = false;
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("flips to failed when refreshAgent rejects", async () => {
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
trigger();
expect(status()).toBe("restoring");
await vi.runAllTicks();
await Promise.resolve();
expect(status()).toBe("failed");
});
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
refreshAgent.mockImplementation(() => Promise.resolve({}));
trigger();
await Promise.resolve();
expect(status()).toBe("restoring");
await vi.advanceTimersByTimeAsync(30000);
expect(status()).toBe("failed");
});
it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
useSessionStore.getState().clearSession(SERVER_ID);
seedArchivedAgent({ worktreeRestore: false });
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBe("needs-host-upgrade");
});
it("is a no-op when the workspace descriptor is already present", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
},
]);
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
});
});