diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index a6778364e4..2488ec8404 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -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: diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 097fd15745..1f25c37f8b 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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(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( diff --git a/packages/app/src/utils/navigate-to-agent/index.ts b/packages/app/src/utils/navigate-to-agent/index.ts index 292c9bbed9..dd69f67d26 100644 --- a/packages/app/src/utils/navigate-to-agent/index.ts +++ b/packages/app/src/utils/navigate-to-agent/index.ts @@ -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; } diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index 8347f1a0f9..4961baa836 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -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 }); diff --git a/packages/app/src/utils/navigate-to-agent/resolve.ts b/packages/app/src/utils/navigate-to-agent/resolve.ts index 95a65229ff..5548506717 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.ts @@ -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, diff --git a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts index 87d3558bf9..2ed6c9143d 100644 --- a/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts +++ b/packages/app/src/utils/navigate-to-agent/restore-archived-workspace.test.ts @@ -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(); });