fix(app): wait for workspace hydration before restore

This commit is contained in:
Mohamed Boudra
2026-07-16 15:10:11 +02:00
parent 9cdbb88520
commit 6152a6044e
2 changed files with 118 additions and 21 deletions
@@ -1,5 +1,6 @@
import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -12,6 +13,48 @@ export type { NavigateToAgentInput } from "./resolve";
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
const RESTORE_TIMEOUT_MS = 30000;
interface PendingWorkspaceRestore {
agentId: string;
unsubscribe: () => void;
}
const pendingWorkspaceRestores = new Map<string, PendingWorkspaceRestore>();
function restoreWhenWorkspacesHydrate(
serverId: string,
agentId: string,
workspaceId: string,
): void {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return;
}
const pending = pendingWorkspaceRestores.get(key);
if (pending) {
pending.agentId = agentId;
return;
}
const restore: PendingWorkspaceRestore = {
agentId,
unsubscribe: () => {},
};
restore.unsubscribe = useSessionStore.subscribe((state) => {
const session = state.sessions[serverId];
if (session && !session.hasHydratedWorkspaces) {
return;
}
restore.unsubscribe();
pendingWorkspaceRestores.delete(key);
if (session) {
restoreArchivedWorkspace(serverId, restore.agentId, workspaceId);
}
});
pendingWorkspaceRestores.set(key, restore);
}
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
const client = snapshot?.client ?? null;
@@ -24,10 +67,17 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
// History carries restore intent explicitly. Workspace lifecycle is separate
// from agent lifecycle, so a closed non-archived agent can legitimately own an
// archived workspace. A present workspace or in-flight restore stays a no-op.
if (session?.workspaces.has(workspaceId)) {
if (!session || session.workspaces.has(workspaceId)) {
return;
}
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
if (session.restoringWorkspaces.get(workspaceId) === "restoring") {
return;
}
// An empty workspace map is not authoritative during startup. Keep the route on
// its existing loading state, then decide whether restoration is needed once the
// daemon's workspace snapshot has landed.
if (!session.hasHydratedWorkspaces) {
restoreWhenWorkspacesHydrate(serverId, agentId, workspaceId);
return;
}
@@ -35,7 +85,7 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
// 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) {
if (session.serverInfo?.features?.worktreeRestore !== true) {
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
return;
}
@@ -20,7 +20,7 @@ vi.mock("@/runtime/host-runtime", () => ({
isHostRuntimeConnected: () => connected,
}));
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { navigateToAgent } from "./index";
const SERVER_ID = "remote-server";
@@ -81,6 +81,25 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
return next;
});
store.setHasHydratedWorkspaces(SERVER_ID, true);
}
function workspace(): WorkspaceDescriptor {
return {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: MISSING_WORKTREE_CWD,
workspaceDirectory: MISSING_WORKTREE_CWD,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
describe("restoreArchivedWorkspace via navigateToAgent", () => {
@@ -162,6 +181,50 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
expect(status()).toBeNull();
});
it("waits for workspace hydration before deciding a running History workspace is missing", () => {
const store = useSessionStore.getState();
store.setHasHydratedWorkspaces(SERVER_ID, false);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, { ...agent(null), status: "running" });
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
openFromHistory();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
store.mergeWorkspaces(SERVER_ID, [workspace()]);
store.setHasHydratedWorkspaces(SERVER_ID, true);
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("restores a missing History workspace after workspace hydration settles", () => {
const store = useSessionStore.getState();
store.setHasHydratedWorkspaces(SERVER_ID, false);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, { ...agent(null), status: "closed" });
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
openFromHistory();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
store.setHasHydratedWorkspaces(SERVER_ID, true);
expect(refreshAgent).toHaveBeenCalledTimes(1);
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
expect(status()).toBe("restoring");
});
it("does not fire while disconnected", () => {
connected = false;
refreshAgent.mockImplementation(() => new Promise(() => {}));
@@ -208,23 +271,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
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: MISSING_WORKTREE_CWD,
workspaceDirectory: MISSING_WORKTREE_CWD,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
},
]);
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspace()]);
openFromHistory();