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.
This commit is contained in:
Mohamed Boudra
2026-07-16 15:10:11 +02:00
parent 6aba0370ae
commit 9cdbb88520
6 changed files with 95 additions and 65 deletions
+5
View File
@@ -52,6 +52,11 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
Cascade is what keeps subagent fleets from outliving their orchestrator.
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History restore must therefore follow explicit restore intent for that missing
workspace identity; it must not infer workspace lifecycle from `agent.archivedAt`.
## Tabs vs archive
These are two distinct concepts that used to be conflated:
+9 -30
View File
@@ -21,8 +21,6 @@ import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useQueryClient } from "@tanstack/react-query";
import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key";
interface AgentListProps {
agents: AggregatedAgent[];
@@ -374,7 +372,6 @@ export function AgentList({
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent();
const queryClient = useQueryClient();
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -391,35 +388,17 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const openAgent = () => {
onAgentSelect?.();
navigateToAgent({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: false,
});
};
if (agent.archivedAt) {
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
if (client) {
void client
.refreshAgent(agentId)
.then(() => {
openAgent();
return queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(serverId),
});
})
.catch(() => {});
}
return;
}
openAgent();
onAgentSelect?.();
navigateToAgent({
serverId,
agentId,
workspaceId: agent.workspaceId,
restoreWorkspace: true,
pin: false,
});
},
[isActionSheetVisible, onAgentSelect, queryClient],
[isActionSheetVisible, onAgentSelect],
);
const handleAgentLongPress = useCallback(
@@ -21,13 +21,9 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
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;
}
// 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)) {
return;
}
@@ -74,12 +74,20 @@ describe("resolveNavigateToAgent", () => {
]);
});
it("delegates a restore attempt whenever a workspaceId resolves", () => {
it("delegates a restore attempt when History carries explicit restore intent", () => {
const { deps, restores, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
resolveNavigateToAgent(
{
serverId: SERVER_ID,
agentId: AGENT_ID,
restoreWorkspace: true,
pin: true,
},
deps,
);
expect(restores).toEqual([
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
@@ -87,6 +95,17 @@ describe("resolveNavigateToAgent", () => {
expect(tabNavigations).toHaveLength(1);
});
it("does not delegate restore during ordinary workspace navigation", () => {
const { deps, restores, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
expect(restores).toEqual([]);
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 });
@@ -8,6 +8,10 @@ export interface NavigateToAgentInput {
// Used as the workspace target when the agent is not yet in the session store
// (cold deep-links). Otherwise the workspace is read from the store.
workspaceId?: string | null;
// History can point at an agent whose owning workspace has been archived even
// when the agent itself was not. Other navigation paths must not refresh an
// agent just because their workspace descriptor is temporarily unavailable.
restoreWorkspace?: boolean;
pin?: boolean;
}
@@ -41,13 +45,13 @@ 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,
});
if (input.restoreWorkspace === true) {
deps.restoreArchivedWorkspace({
serverId: input.serverId,
agentId: input.agentId,
workspaceId,
});
}
return deps.navigateToWorkspace({
serverId: input.serverId,
@@ -23,9 +23,10 @@ vi.mock("@/runtime/host-runtime", () => ({
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";
const SERVER_ID = "remote-server";
const AGENT_ID = "00000000-0000-4000-8000-000000000001";
const WORKSPACE_ID = "wks_history_restore";
const MISSING_WORKTREE_CWD = "/home/tester/.paseo/worktrees/missing-history-worktree";
function agent(archivedAt: Date | null): Agent {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
@@ -51,7 +52,7 @@ function agent(archivedAt: Date | null): Agent {
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo",
cwd: MISSING_WORKTREE_CWD,
workspaceId: WORKSPACE_ID,
model: null,
archivedAt,
@@ -96,14 +97,24 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
useSessionStore.getState().clearSession(SERVER_ID);
});
function trigger(): void {
function openFromHistory(): void {
const input = {
serverId: SERVER_ID,
agentId: AGENT_ID,
workspaceId: WORKSPACE_ID,
restoreWorkspace: true,
};
navigateToAgent(input);
}
function navigateNormally(): void {
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
}
it("calls refreshAgent once and marks the workspace restoring", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
openFromHistory();
expect(refreshAgent).toHaveBeenCalledTimes(1);
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
@@ -113,23 +124,39 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
it("does not re-fire while a restore for the same workspace is in flight", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
trigger();
trigger();
openFromHistory();
openFromHistory();
openFromHistory();
expect(refreshAgent).toHaveBeenCalledTimes(1);
});
it("does not fire for a non-archived agent", () => {
it("restores a History-opened closed agent whose archived workspace is missing", () => {
const store = useSessionStore.getState();
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, agent(null));
next.set(AGENT_ID, { ...agent(null), status: "closed" });
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
openFromHistory();
expect(refreshAgent).toHaveBeenCalledTimes(1);
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
expect(status()).toBe("restoring");
});
it("does not restore during ordinary active-workspace navigation", () => {
const store = useSessionStore.getState();
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, { ...agent(null), status: "closed" });
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
navigateNormally();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
@@ -139,7 +166,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
connected = false;
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
openFromHistory();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
@@ -148,7 +175,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
it("flips to failed when refreshAgent rejects", async () => {
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
trigger();
openFromHistory();
expect(status()).toBe("restoring");
await vi.runAllTicks();
@@ -160,7 +187,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
refreshAgent.mockImplementation(() => Promise.resolve({}));
trigger();
openFromHistory();
await Promise.resolve();
expect(status()).toBe("restoring");
@@ -173,7 +200,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
seedArchivedAgent({ worktreeRestore: false });
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
openFromHistory();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBe("needs-host-upgrade");
@@ -186,8 +213,8 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectRootPath: MISSING_WORKTREE_CWD,
workspaceDirectory: MISSING_WORKTREE_CWD,
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
@@ -199,7 +226,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
},
]);
trigger();
openFromHistory();
expect(refreshAgent).not.toHaveBeenCalled();
});