diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index ddf69fd7c5..57bd7889cb 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -109,16 +109,28 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. -History navigation preserves the selected agent as an explicit recovery target. If both that agent -and its workspace are archived, the workspace recovery action restores the workspace and unarchives -the selected agent as one user action. Other archived agents in the restored workspace remain -recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative -timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must -leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains -the only transition back to an interactive runtime: it runs the provider's native unarchive hook -(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A -provider session can be archived outside Paseo while its Paseo agent remains active. Interactive -resume repairs that drift through the provider's native unarchive hook; history resume does not. +History navigation opens the selected agent without changing either archive state. Workspace +**Restore** recovers only the workspace; the selected archived agent stays open with its callout. +The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and +history hydration. Other archived agents stay archived. + +Opening an agent is a navigation choice, independent of whether its details are cached. The +layout retains that choice across reload while the panel fetches the agent from the daemon. +Once the daemon reports the agent active, its tab follows normal archive propagation again. +An empty active list cannot cancel an explicit History selection. Agent-detail loading does +not own selection or release the explicit open. + +Persisted resume, native restore, and both live and stored-only archive enter the same per-agent +lifecycle queue. Resume chooses its history or interactive purpose from the durable record after +entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed +close retains the runtime for cleanup and blocks replacement through that close operation. + +Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that +purpose initializes a temporary app-server, reads the persisted thread and child histories, and +releases the process before returning. It never loads, resumes, or unarchives a native thread, +including legacy records whose native archive failed. The retained history session contains only +the read results. Interactive resume remains responsible for repairing a provider session archived +outside Paseo while its Paseo agent is active. Provider session connection owns every process it spawns until the session is registered with `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 5d3464a30b..02c5723162 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -95,6 +95,11 @@ owner wraps cached preparation, network catch-up, accepted timeline application, behind one interface. React supplies transport and projection operations without selecting a cache path or issuing a separate persistence notification. +Active-agent list reconciliation does not own transcript lifetime. An archived agent's fresh +history response can arrive before the active-list response that omits it. Clearing history there +would discard accepted rows while the viewed owner still considers them synchronized. Entity +deletion clears the transcript; list membership changes do not. + Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together. diff --git a/packages/app/e2e/browser/root-error-recovery.spec.ts b/packages/app/e2e/browser/root-error-recovery.spec.ts index 26fc70c4bb..55f5f37b42 100644 --- a/packages/app/e2e/browser/root-error-recovery.spec.ts +++ b/packages/app/e2e/browser/root-error-recovery.spec.ts @@ -4,9 +4,15 @@ import { expect, test, type Page, type TestInfo } from "@playwright/test"; let server: Server; let appUrl: string; test.beforeAll(async () => { + // Compile this separate fixture entrypoint in setup, like the main app warmup. + // Cold Metro compilation must not consume the recovery interaction's timeout. + test.setTimeout(120_000); const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) throw new Error("E2E_METRO_PORT is required"); const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`; + const compiled = await fetch(bundle, { signal: AbortSignal.timeout(120_000) }); + if (!compiled.ok) throw new Error(`Recovery fixture compilation failed: HTTP ${compiled.status}`); + await compiled.arrayBuffer(); server = createServer((_request, response) => { response.setHeader("Content-Type", "text/html"); response.end( diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index 33e6ff8ddc..336ed97245 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -7,7 +7,6 @@ import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { selectSidebarStatusGrouping } from "../support/helpers/sidebar"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; -import { getVisibleWorkspaceAgentTabIds } from "../support/helpers/workspace-tabs"; // Model B sidebar shape: every project — git or non-git, single- or // multi-workspace — renders as the same expandable parent, the deepest sidebar @@ -96,8 +95,9 @@ test.describe("Model B sidebar shape", () => { try { // Open the workspace and materialize both an agent tab and a terminal tab. await gotoWorkspace(page, mock.workspaceId); - const agentTabs = await getVisibleWorkspaceAgentTabIds(page); - expect(agentTabs).toContain(`workspace-tab-agent_${mock.agentId}`); + await expect( + page.getByTestId(`workspace-tab-agent_${mock.agentId}`).filter({ visible: true }), + ).toBeVisible(); await clickNewTerminal(page); await expect( diff --git a/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts new file mode 100644 index 0000000000..8b9010e742 --- /dev/null +++ b/packages/app/e2e/browser/worktree-codex-restore.real.spec.ts @@ -0,0 +1,63 @@ +import { test } from "../support/helpers/codex-workspace-restore"; + +test.use({ + e2eDaemonConfig: { version: 1, daemon: { mcp: { enabled: true } } }, + trace: "on", + video: "on", +}); +test.describe.configure({ timeout: 600_000 }); + +test("a fresh client restores an archived Codex agent across a reload without agent cache", async ({ + codexRestore, +}) => { + await test.step("Create, finish, and archive Codex before this browser connects", async () => { + await codexRestore.createWorktreeWithCodex(); + await codexRestore.archiveBeforeBrowserConnects(); + }); + await test.step("Connect the fresh browser, open History, and restore the workspace", async () => { + await codexRestore.connectFreshBrowser(); + await codexRestore.openArchivedWorkspaceFromHistory(); + await codexRestore.restoreWorkspace(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Reload without cached agent data and keep the selected archived agent", async () => { + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.waitForWorkspaceHydration(); + await codexRestore.expectArchivedAgentSelectedWithHistory(); + }); + await test.step("Unarchive the agent and keep its idle composer after another cache-empty reload", async () => { + await codexRestore.unarchiveAgent(); + await codexRestore.reloadWithoutAgentCache(); + await codexRestore.expectIdleAgentWithVisibleComposer(); + }); +}); + +test("restore an archived worktree, then unarchive its completed Codex agent", async ({ + codexRestore, +}) => { + await test.step("1. Create a managed worktree with a real Codex agent through Paseo MCP", async () => { + await codexRestore.createWorktreeWithCodex(); + }); + await test.step("2. Wait for Codex to finish and show its reply", async () => { + await codexRestore.waitForCompletedReply(); + }); + await test.step("3. Archive the workspace from its sidebar menu", async () => { + await codexRestore.archiveWorkspaceFromSidebar(); + }); + await test.step("4. Assert workspace and agent are archived and the worktree is removed", async () => { + await codexRestore.expectWorkspaceAndAgentArchived(); + }); + await test.step("5. Open the archived workspace from History", async () => { + await codexRestore.openArchivedWorkspaceFromHistory(); + }); + await test.step("6. Restore the workspace", async () => { + await codexRestore.restoreWorkspace(); + await codexRestore.expectWorktreeRestoredWithArchivedAgentSelected(); + }); + await test.step("7. Click Unarchive on the agent", async () => { + await codexRestore.unarchiveAgent(); + }); + await test.step("8. Expect an idle agent with a visible editable composer", async () => { + await codexRestore.expectIdleAgentWithVisibleComposer(60_000); + }); +}); diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index 4f6096ed07..3926b43a97 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -265,7 +265,7 @@ test.describe("Worktree restore", () => { ).toHaveText(switchedBranch, { timeout: 30_000 }); }); - test("recovers the selected agent with its workspace and later rescues another archived agent", async ({ + test("restores the workspace before explicitly unarchiving each selected agent", async ({ page, }) => { const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", { @@ -292,9 +292,16 @@ test.describe("Worktree restore", () => { await expect( page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), ).toBeVisible({ timeout: 30_000 }); + await expect( + page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(), + ).toHaveAttribute("aria-selected", "true"); + expect(await fetchAgentArchivedAt(client, firstAgent.id)).not.toBeNull(); + expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); await expect .poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 }) .toBeNull(); + expect(await fetchAgentArchivedAt(client, secondAgent.id)).not.toBeNull(); await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, { timeout: 30_000, }); diff --git a/packages/app/e2e/support/helpers/codex-workspace-restore.ts b/packages/app/e2e/support/helpers/codex-workspace-restore.ts new file mode 100644 index 0000000000..7595b4eb20 --- /dev/null +++ b/packages/app/e2e/support/helpers/codex-workspace-restore.ts @@ -0,0 +1,277 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { z } from "zod"; +import type { TestInfo } from "@playwright/test"; +import { test as base, expect, type Page } from "../fixtures"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { openSessions } from "./archive-tab"; +import { getE2EDaemonPort } from "./daemon-port"; +import { assertComposerIdle } from "./rewind-flow"; +import { getServerId } from "./server-id"; +import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./sidebar"; +import { createTempGitRepo } from "./workspace"; +import { waitForSidebarHydration } from "./workspace-ui"; +import type { SeedDaemonClient } from "./seed-client"; + +const REPLY = "CODEX_WORKTREE_RESTORE_READY"; +const workspaceSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + isolation: z.literal("worktree"), +}); + +async function callPaseoTool( + mcp: Client, + name: string, + args: Record, +): Promise { + const result = await mcp.callTool({ name, arguments: args }, undefined, { timeout: 240_000 }); + expect(result).not.toMatchObject({ isError: true }); + return result.structuredContent; +} + +async function defaultCodexModel(mcp: Client): Promise { + const result = z + .object({ models: z.array(z.object({ id: z.string(), isDefault: z.boolean() })) }) + .parse(await callPaseoTool(mcp, "list_models", { provider: "codex" })); + const model = result.models.find((entry) => entry.isDefault); + if (!model) throw new Error("Codex did not advertise a default model"); + return model.id; +} + +function recordTimelineWire(page: Page): string[] { + const wire: string[] = []; + page.on("websocket", (socket) => { + const recordFrame = (direction: string, message: string) => { + if (/agent.*timeline|fetch_agents|agent_update/.test(message)) { + wire.push(JSON.stringify({ at: Date.now(), direction, message })); + } + }; + socket.on("framesent", ({ payload }) => recordFrame("sent", payload.toString())); + socket.on("framereceived", ({ payload }) => recordFrame("received", payload.toString())); + }); + return wire; +} + +async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, info: TestInfo) { + const repo = await createTempGitRepo("codex-worktree-restore-"); + const mcp = new Client({ name: "worktree-codex-restore-playwright", version: "1.0.0" }); + const wire = recordTimelineWire(page); + let workspace: z.infer | undefined; + let agentId: string | undefined; + + function created() { + if (!workspace || !agentId) throw new Error("Create the Codex worktree before using it"); + return { ...workspace, agentId }; + } + + async function expectSelected() { + await expect(page.getByTestId(`workspace-tab-agent_${created().agentId}`)).toHaveAttribute( + "aria-selected", + "true", + ); + } + + return { + async createWorktreeWithCodex() { + await mcp.connect( + new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents`), + ), + ); + workspace = workspaceSchema.parse( + await callPaseoTool(mcp, "create_workspace", { + isolation: "worktree", + path: repo.path, + baseBranch: "main", + title: "Codex worktree restore reproduction", + }), + ); + const agent = z.object({ agentId: z.string() }).parse( + await callPaseoTool(mcp, "create_agent", { + workspaceId: workspace.workspaceId, + provider: `codex/${await defaultCodexModel(mcp)}`, + settings: { modeId: "full-access", thinkingOptionId: "low" }, + title: "Codex worktree restore reproduction", + initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`, + background: true, + }), + ); + agentId = agent.agentId; + }, + async waitForCompletedReply() { + const target = created(); + await page.goto(buildHostAgentDetailRoute(getServerId(), target.agentId, target.workspaceId)); + await waitForSidebarHydration(page); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY, { + timeout: 240_000, + }); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { status: "idle", archivedAt: null }, + }); + await assertComposerIdle({ page }); + }, + async archiveBeforeBrowserConnects() { + const target = created(); + await client.waitForFinish(target.agentId, 240_000); + expect((await client.archiveWorkspace(target.workspaceId)).error).toBeNull(); + await expect.poll(() => existsSync(target.cwd)).toBe(false); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + }, + async connectFreshBrowser() { + await page.goto("/"); + }, + async archiveWorkspaceFromSidebar() { + await archiveWorkspaceFromSidebar(page, created().workspaceId); + }, + async expectWorkspaceAndAgentArchived() { + const target = created(); + await expectWorkspaceAbsentFromSidebar(page, target.workspaceId); + await expect + .poll(() => client.fetchAgent({ agentId: target.agentId })) + .toMatchObject({ + agent: { archivedAt: expect.any(String) }, + }); + await expect.poll(() => existsSync(target.cwd), { timeout: 30_000 }).toBe(false); + await expect + .poll(() => client.fetchWorkspaces()) + .not.toMatchObject({ + entries: expect.arrayContaining([expect.objectContaining({ id: target.workspaceId })]), + }); + }, + async openArchivedWorkspaceFromHistory() { + await openSessions(page); + const row = page.getByTestId(`agent-row-${getServerId()}-${created().agentId}`); + await expect(row).toContainText("Archived"); + await row.click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); + }, + async restoreWorkspace() { + await page.getByRole("button", { name: "Restore", exact: true }).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectWorktreeRestoredWithArchivedAgentSelected() { + await expect.poll(() => existsSync(created().cwd), { timeout: 30_000 }).toBe(true); + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await info.attach("after-workspace-restore", { + body: JSON.stringify(await client.fetchAgent({ agentId: created().agentId }), null, 2), + contentType: "application/json", + }); + }, + async expectArchivedAgentSelectedWithHistory() { + await expectSelected(); + await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible(); + await expect(page.getByTestId("assistant-message")).toContainText(REPLY); + }, + async reloadWithoutAgentCache() { + const url = page.url(); + const cdp = await page.context().newCDPSession(page); + // Unmount first so an open connection cannot repopulate the cache while it is cleared. + await page.goto("about:blank"); + await cdp.send("Storage.clearDataForOrigin", { + origin: new URL(url).origin, + storageTypes: "indexeddb,cache_storage", + }); + await cdp.send("Network.clearBrowserCache"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: true }); + await page.goto(url); + await cdp.detach(); + }, + async waitForWorkspaceHydration() { + await waitForSidebarHydration(page); + }, + async unarchiveAgent() { + await page.getByRole("button", { name: "Unarchive", exact: true }).click({ timeout: 30_000 }); + await expect(page.getByText("This agent is archived", { exact: true })).toHaveCount(0, { + timeout: 60_000, + }); + }, + async expectIdleAgentWithVisibleComposer(statusTimeout?: number) { + await expect + .poll(() => client.fetchAgent({ agentId: created().agentId }), { timeout: statusTimeout }) + .toMatchObject({ agent: { status: "idle", archivedAt: null } }); + await expectSelected(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible(); + await assertComposerIdle({ page }); + await info.attach("restored-agent-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("restored-agent-accessibility", { + body: await page.locator("body").ariaSnapshot(), + contentType: "text/plain", + }); + }, + async dispose() { + const testFailed = info.status !== info.expectedStatus; + const errors: unknown[] = []; + try { + await info.attach("final-screen", { + body: await page.screenshot(), + contentType: "image/png", + }); + await info.attach("timeline-wire", { body: wire.join("\n"), contentType: "text/plain" }); + await info.attach("final-agent", { + body: JSON.stringify(agentId ? await client.fetchAgent({ agentId }) : null, null, 2), + contentType: "application/json", + }); + await info.attach("daemon-log", { + body: await readFile(path.join(process.env.E2E_PASEO_HOME!, "daemon.log")), + contentType: "text/plain", + }); + } catch (error) { + errors.push(error); + } + const cleanup: Array<() => Promise> = [() => mcp.close()]; + if (!testFailed || process.env.E2E_KEEP_PASEO_HOME !== "1") { + const projectId = workspace?.projectId; + if (projectId) cleanup.push(() => client.removeProject(projectId)); + cleanup.push(() => repo.cleanup()); + } + for (const release of cleanup) { + try { + await release(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + const error = new AggregateError(errors, "Codex restore fixture teardown failed"); + if (!testFailed) throw error; + // Keep the journey failure primary while reporting teardown failures in the test log. + console.error(error); + } + }, + }; +} + +export const test = base.extend<{ + codexRestore: Awaited>; +}>({ + // This fixture owns cleanup so a failed reproduction can retain its daemon state. + projectOwnership: async ({ e2eWorkerClient }, provide) => { + void e2eWorkerClient; + await provide(); + }, + codexRestore: async ({ page, e2eWorkerClient }, provide, info) => { + const journey = await createCodexRestoreJourney(page, e2eWorkerClient, info); + try { + await provide(journey); + } finally { + await journey.dispose(); + } + }, +}); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index 8396d00b36..7dc11b08f1 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -115,8 +115,7 @@ function HostWorkspaceRouteContent() { const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId); const workspaceExists = useWorkspaceExists(serverId, workspaceId); const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]); - const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null; - const isAgentOpenIntent = recoveryAgentId !== null; + const isAgentOpenIntent = openIntent?.kind === "agent"; const isOpenIntentWaitingForWorkspace = Boolean( isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists), ); @@ -191,16 +190,10 @@ function HostWorkspaceRouteContent() { return null; } - return ; + return ; } -function WorkspaceDeck({ - recoveryRequested, - recoveryAgentId, -}: { - recoveryRequested: boolean; - recoveryAgentId: string | null; -}) { +function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) { const activeSelection = useActiveWorkspaceSelection(); const [retainedSelections, setRetainedSelections] = useState(() => activeSelection ? [{ selection: activeSelection, inactiveSince: null }] : [], @@ -272,7 +265,6 @@ function WorkspaceDeck({ selection={selection} active={active} recoveryRequested={recoveryRequested} - recoveryAgentId={recoveryAgentId} onUnmountInactive={unmountWorkspaceSelection} /> ); @@ -286,13 +278,11 @@ function WorkspaceDeckEntry({ selection, active, recoveryRequested, - recoveryAgentId, onUnmountInactive, }: { selection: ActiveWorkspaceSelection; active: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; }) { const hasHydratedWorkspaces = useHasHydratedWorkspaces(selection.serverId); @@ -323,7 +313,6 @@ function WorkspaceDeckEntry({ workspaceId={selection.workspaceId} isRouteFocused={active} recoveryRequested={active && recoveryRequested} - recoveryAgentId={active ? recoveryAgentId : null} /> ); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index c2129c654c..84f86bf745 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -626,8 +626,6 @@ function AgentPanelBody({ const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); - const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; @@ -639,9 +637,6 @@ function AgentPanelBody({ return; } if (agentState.id) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -664,9 +659,6 @@ function AgentPanelBody({ return; } if (!result) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -675,9 +667,6 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "idle" }); return; }) @@ -687,25 +676,12 @@ function AgentPanelBody({ } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); - }, [ - agentId, - agentState.id, - client, - hasSession, - isConnected, - lookupState.tag, - resolvePendingAgent, - serverId, - workspaceKey, - ]); + }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); if (lookupState.tag === "not_found") { return ( diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index d7deff40a7..316e8e03bf 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + selectAgentTimelineState, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; @@ -53,6 +57,50 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it.each(["delta", "snapshot"] as const)( + "does not erase freshly fetched history when the active directory removes it by %s", + (removal) => { + const serverId = `agent-replica-history-${removal}`; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" }; + replica.submitTimelineAgent(replica.captureTimeline("agent"), archived); + store.applyAgentTimelineResponseState(serverId, "agent", { + items: [ + { + kind: "assistant_message", + id: "reply", + text: "server history", + timestamp: new Date(), + timelineCursor: { epoch: "e", seq: 1 }, + }, + ], + head: [], + range: { epoch: "e", startSeq: 1, endSeq: 1 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + if (removal === "delta") replica.applyDelta({ kind: "remove", agentId: "agent" }); + else replica.commitSnapshot([], []); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "synced", + items: [{ text: "server history" }], + }); + replica.remove("agent"); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "cold", + }); + store.clearSession(serverId); + }, + ); + it("does not let a late cache read replace newer live turn state", () => { const serverId = "agent-replica-late-cache"; const store = useSessionStore.getState(); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 1bdf3ea3de..dac4ec5d28 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -134,7 +134,7 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); + if (!nextIds.has(agentId)) this.storeProjection.removeFromDirectory(agentId); } this.members.clear(); this.pendingCacheReads.clear(); diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts index 0566da5aaf..97c8208da5 100644 --- a/packages/app/src/runtime/directory-sync/internal/agent-store.ts +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -12,6 +12,15 @@ import { useDraftStore } from "@/stores/draft-store"; import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; +function withoutAgent(agentId: string) { + return (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; +} + function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { if (!previous) return incoming; const activeTurn = @@ -65,7 +74,7 @@ export class AgentStoreProjection { agent?: Agent; } { if (delta.kind === "remove") { - this.remove(delta.agentId); + this.removeFromDirectory(delta.agentId); return { agentId: delta.agentId, stoppedRunning: false }; } const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); @@ -141,28 +150,19 @@ export class AgentStoreProjection { useSessionStore.getState().setPendingPermissions(this.serverId, pending); } - remove(agentId: string): void { + removeFromDirectory(agentId: string): void { const store = useSessionStore.getState(); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; + const removeKey = withoutAgent(agentId); clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); store.setAgents(this.serverId, removeKey); store.setAgentDetails(this.serverId, removeKey); store.setQueuedMessages(this.serverId, removeKey); - store.setAgentTimelineCursor(this.serverId, removeKey); store.setInitializingAgents(this.serverId, removeKey); store.setPendingPermissions(this.serverId, (current) => { const next = new Map(current); for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); return next.size === current.size ? current : next; }); - store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); - store.setAgentStreamTail(this.serverId, removeKey); - store.clearAgentStreamHead(this.serverId, agentId); useSessionStore.setState((state) => { if (!state.agentLastActivity.has(agentId)) return state; const agentLastActivity = new Map(state.agentLastActivity); @@ -178,6 +178,18 @@ export class AgentStoreProjection { } } + remove(agentId: string): void { + this.removeFromDirectory(agentId); + // Only entity deletion destroys the transcript. A filtered active-list + // response can arrive after a fresh history response for an archived agent. + const store = useSessionStore.getState(); + const removeKey = withoutAgent(agentId); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + } + archive(agentId: string, archivedAt: string): Agent | null { let archived: Agent | null = null; useSessionStore.getState().setAgents(this.serverId, (current) => { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d9510f517f..a898fc0472 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -257,13 +257,11 @@ interface WorkspaceScreenProps { workspaceId: string; isRouteFocused?: boolean; recoveryRequested?: boolean; - recoveryAgentId?: string | null; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -859,7 +857,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); useEffect(() => { @@ -874,7 +871,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} - recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -1539,7 +1535,6 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1665,7 +1660,6 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 9496479f4e..d46952626e 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -252,7 +252,6 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; - pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; explorerSidebarPaneId: string | null; } @@ -262,7 +261,6 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; - knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -2238,17 +2236,10 @@ interface EntityTabGroup { function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; - pendingAgentIds: Set; hiddenAgentIds: Set; - knownAgentIds: Set; }): Set { - const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; - const result = new Set(baseAgentIds); - for (const agentId of pinnedAgentIds) { - if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { - result.add(agentId); - } - } + const { baseAgentIds, pinnedAgentIds, hiddenAgentIds } = input; + const result = new Set([...baseAgentIds, ...pinnedAgentIds]); for (const agentId of hiddenAgentIds) { result.delete(agentId); } @@ -2414,12 +2405,17 @@ export function reconcileWorkspaceTabs( const originalFocusedTabId = findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; - const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); - const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); + // An explicit open owns the tab until the agent joins the active directory. + // From then on it follows the normal server archive lifecycle. Detail/cache + // hydration never decides whether the user's target is allowed to stay open. + const pinnedAgentIds = new Set( + [...(state.pinnedAgentIds ?? [])].filter( + (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), + ), + ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds ? normalizeStringSet(snapshot.knownTerminalIds) @@ -2427,16 +2423,12 @@ export function reconcileWorkspaceTabs( const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const initialTabs = collectAllTabs(nextLayout.root); @@ -2513,5 +2505,6 @@ export function reconcileWorkspaceTabs( return { ...state, layout: nextLayout, + pinnedAgentIds, }; } diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index e53f9c1911..4f861a04ee 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -450,7 +450,7 @@ describe("workspace-layout-store version 2 migration", () => { }); }); - it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => { + it("writes version 2 preserving the existing Explorer key", async () => { await persistVersionOneLayout("v0.6"); const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); @@ -463,6 +463,7 @@ describe("workspace-layout-store version 2 migration", () => { "explorerPaneIdByWorkspace", "explorerSidebarWidthByWorkspace", "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", "sidePaneIdByWorkspace", "splitSizesByWorkspace", ]); @@ -1483,7 +1484,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: true, @@ -1497,7 +1497,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], }); @@ -1580,7 +1579,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1", "agent-2"], autoOpenAgentIds: ["agent-1", "agent-2"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: [], }); @@ -2943,6 +2941,7 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: {}, @@ -3092,7 +3091,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); - it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => { + it("persists explicit agent opens per workspace", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -3131,7 +3130,9 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); - expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace"); + expect(partialize?.(state)).toHaveProperty("pinnedAgentIdsByWorkspace", { + [otherWorkspaceKey as string]: ["agent-2"], + }); }); it("keeps hidden agent intents in memory per workspace without persisting them", () => { @@ -3166,6 +3167,7 @@ describe("workspace-layout-store actions", () => { expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ layoutByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, explorerPaneIdByWorkspace: {}, @@ -3258,7 +3260,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: ["term-1"], hasActivePendingDraftCreate: false, }); @@ -3314,7 +3315,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3346,7 +3346,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3362,7 +3361,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3380,7 +3378,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3444,7 +3441,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: false, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], }); @@ -3467,7 +3463,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3495,7 +3490,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3522,7 +3516,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3549,7 +3542,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3571,7 +3563,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["child-agent"], autoOpenAgentIds: [], - knownAgentIds: ["child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3609,7 +3600,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script", "term-manual"], standaloneTerminalIds: ["term-manual"], hasActivePendingDraftCreate: false, @@ -3629,7 +3619,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3675,7 +3664,7 @@ describe("workspace-layout-store actions", () => { expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); - it("keeps an explicitly pinned archived agent before its detail is hydrated", () => { + it("keeps an explicitly opened agent when its detail leaves client memory", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3690,7 +3679,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3706,7 +3694,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3717,13 +3704,11 @@ describe("workspace-layout-store actions", () => { .map((tab) => tab.tabId), ).toEqual(["agent_archived-agent"]); - store.resolvePendingAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3732,7 +3717,7 @@ describe("workspace-layout-store actions", () => { .getWorkspaceTabs(workspaceKey) .filter((tab) => tab.target.kind === "agent") .map((tab) => tab.tabId), - ).toEqual([]); + ).toEqual(["agent_archived-agent"]); store.openTab({ workspaceKey: workspaceKey, @@ -3745,7 +3730,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3757,6 +3741,57 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_archived-agent"]); }); + it("restores the selected historical agent before any server data or cache is available", async () => { + const workspaceKey = createWorkspaceKey(); + workspaceLayoutStore.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + pin: true, + }); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + restored.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + const layout = restored.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe( + "agent_archived-agent", + ); + }); + + it("follows server archive after an explicitly opened agent becomes active", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + standaloneTerminalIds: [], + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + expect(store.getWorkspaceTabs(workspaceKey).some((tab) => tab.target.kind === "agent")).toBe( + false, + ); + }); + it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3783,7 +3818,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["first-agent", "second-agent"], autoOpenAgentIds: ["first-agent", "second-agent"], - knownAgentIds: ["first-agent", "second-agent"], standaloneTerminalIds: [], }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 974b8c6964..afb1d2694d 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -107,7 +107,6 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; pinnedAgentIdsByWorkspace: Record>; - pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; explorerSidebarPaneIdByWorkspace: Record; @@ -130,7 +129,6 @@ interface WorkspaceLayoutStore { setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void; convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; - resolvePendingAgent: (workspaceKey: string, agentId: string) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -270,6 +268,7 @@ const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject( parentTabIdByTabId: z.record(z.string(), z.string()).optional(), }); const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(), @@ -764,7 +763,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, - pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, @@ -830,13 +828,6 @@ export function createWorkspaceLayoutStore( normalizedTarget.agentId, ) : state.pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: shouldPinAgent - ? addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ) - : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -1212,7 +1203,6 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, explorerSidebarPaneId, }, @@ -1223,11 +1213,25 @@ export function createWorkspaceLayoutStore( explorerSidebarPaneId, currentLayout.focusedPaneId, ); - if (nextLayout === rawLayout) { + let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace; + for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) { + if (!nextState.pinnedAgentIds?.has(agentId)) { + pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + pinnedAgentIdsByWorkspace, + normalizedWorkspaceKey, + agentId, + ); + } + } + if ( + nextLayout === rawLayout && + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + ) { return state; } return { + pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1235,25 +1239,6 @@ export function createWorkspaceLayoutStore( }; }); }, - resolvePendingAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ); - if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) { - return state; - } - return { pendingAgentIdsByWorkspace }; - }); - }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -1676,11 +1661,6 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; } @@ -1692,11 +1672,6 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; }); }, @@ -1756,7 +1731,6 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || - normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || @@ -1774,8 +1748,6 @@ export function createWorkspaceLayoutStore( } = state.explorerSidebarWidthByWorkspace; const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = state.pinnedAgentIdsByWorkspace; - const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } = - state.pendingAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = @@ -1791,7 +1763,6 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace, explorerSidebarWidthByWorkspace, pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, explorerSidebarPaneIdByWorkspace, @@ -1817,6 +1788,12 @@ export function createWorkspaceLayoutStore( } return { layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ + key, + Array.from(agentIds), + ]), + ), splitSizesByWorkspace: state.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, @@ -1855,6 +1832,12 @@ export function createWorkspaceLayoutStore( return { ...currentState, layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [ + key, + new Set(agentIds), + ]), + ), splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, explorerSidebarWidthByWorkspace: result.data.explorerSidebarWidthByWorkspace ?? diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index c8e2479894..0362aa56de 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -187,55 +187,60 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); - it("removes every replica-owned artifact for a removed agent", () => { - const serverId = "server-removal"; - const agentId = "removed-agent"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - const agent = { - ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), - projectPlacement: null, - }; - store.setAgents(serverId, new Map([[agentId, agent]])); - store.setAgentDetails(serverId, new Map([[agentId, agent]])); - store.setQueuedMessages( - serverId, - new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), - ); - store.setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), - ); - store.setPendingPermissions( - serverId, - new Map([["permission", { key: "permission", agentId, request: null as never }]]), - ); - store.setInitializingAgents(serverId, new Map([[agentId, true]])); - setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + it.each(["directory", "entity"] as const)( + "cleans removed %s state without invalidating an independent transcript", + (kind) => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); + store.setPendingPermissions( + serverId, + new Map([["permission", { key: "permission", agentId, request: null as never }]]), + ); + store.setInitializingAgents(serverId, new Map([[agentId, true]])); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); + const projection = new AgentStoreProjection(serverId); + if (kind === "directory") projection.applyDelta({ kind: "remove", agentId }); + else projection.remove(agentId); - const session = useSessionStore.getState().sessions[serverId]; - expect({ - agents: session?.agents.has(agentId), - details: session?.agentDetails.has(agentId), - queued: session?.queuedMessages.has(agentId), - cursor: session?.agentTimelineCursor.has(agentId), - permissions: session?.pendingPermissions.size, - initializing: session?.initializingAgents.has(agentId), - archivePending: isAgentArchiving({ queryClient, serverId, agentId }), - }).toEqual({ - agents: false, - details: false, - queued: false, - cursor: false, - permissions: 0, - initializing: false, - archivePending: false, - }); + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + initializing: session?.initializingAgents.has(agentId), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: kind === "directory", + permissions: 0, + initializing: false, + archivePending: false, + }); - store.clearSession(serverId); - }); + store.clearSession(serverId); + }, + ); it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { const serverId = "server-usage"; diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts index 46f7aba864..c4a6de7a21 100644 --- a/packages/app/src/workspace-recovery/model.test.ts +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model"; +import { resolveWorkspaceRecoveryModel } from "./model"; describe("resolveWorkspaceRecoveryModel", () => { it("keeps newer recovery actions visible but non-actionable", () => { @@ -29,24 +29,3 @@ describe("resolveWorkspaceRecoveryModel", () => { }); }); }); - -describe("recoverWorkspaceSelection", () => { - it("restores the workspace and selected archived agent as one recovery action", async () => { - const operations: string[] = []; - - await recoverWorkspaceSelection({ - workspaceId: "workspace-1", - agentId: "agent-1", - client: { - restoreWorkspace: async (workspaceId) => { - operations.push(`workspace:${workspaceId}`); - }, - refreshAgent: async (agentId) => { - operations.push(`agent:${agentId}`); - }, - }, - }); - - expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]); - }); -}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index 2c85a06f4a..5d5ebf15e0 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -34,22 +34,6 @@ export interface WorkspaceRecoveryController { retryInspection: () => void; } -export interface WorkspaceSelectionRecoveryClient { - restoreWorkspace: (workspaceId: string) => Promise; - refreshAgent: (agentId: string) => Promise; -} - -export async function recoverWorkspaceSelection(input: { - client: WorkspaceSelectionRecoveryClient; - workspaceId: string; - agentId?: string | null; -}): Promise { - await input.client.restoreWorkspace(input.workspaceId); - if (input.agentId) { - await input.client.refreshAgent(input.agentId); - } -} - function resolveRecoveryPhase(input: { pending: boolean; error: string | null; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index ab19957190..d91aa3ce6e 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -4,11 +4,7 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { toErrorMessage } from "@/utils/error-messages"; -import { - recoverWorkspaceSelection, - resolveWorkspaceRecoveryModel, - type WorkspaceRecoveryController, -} from "./model"; +import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model"; export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model"; @@ -32,7 +28,6 @@ function waitForMinimumRecoveryLoadingTime(): Promise { export function useWorkspaceRecovery(input: { serverId: string; workspaceId: string; - agentId?: string | null; enabled: boolean; }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); @@ -61,11 +56,7 @@ export function useWorkspaceRecovery(input: { } await waitForRecoveryLoadingPresentation(); await waitForMinimumRecoveryLoadingTime(); - await recoverWorkspaceSelection({ - client, - workspaceId: input.workspaceId, - agentId: input.agentId, - }); + await client.restoreWorkspace(input.workspaceId); }, }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index bece625a89..3b16c6b8d5 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -61,7 +61,7 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { - it("keeps subagents active and known while excluding them from auto-open", () => { + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", cwd: "/repo/worktree", @@ -84,10 +84,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); }); - it("keeps archived subagents known but excludes them from active and auto-open", () => { + it("excludes archived subagents from active and auto-open", () => { const archivedChild = makeAgent({ id: "archived-child", cwd: "/repo/worktree", @@ -103,7 +102,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set()); expect(result.autoOpenAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set(["archived-child"])); }); it("excludes a child from auto-open even when its snapshot arrives before the parent", () => { @@ -129,7 +127,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); }); it("auto-opens a subagent whose parent belongs to another workspace", () => { @@ -155,10 +152,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent"])); }); - it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { + it("excludes archived agents from the active directory", () => { const visible = makeAgent({ id: "visible-agent", cwd: "/repo/worktree", @@ -191,12 +187,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"])); - expect(result.knownAgentIds.has("visible-agent")).toBe(true); - expect(result.knownAgentIds.has("archived-agent")).toBe(true); - expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); - it("treats lazy historical details as known without making them active", () => { + it("does not make historical details active", () => { const active = makeAgent({ id: "active-agent", cwd: "/repo/worktree", @@ -216,7 +209,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["active-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"])); }); it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { @@ -281,7 +273,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"])); }); it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => { @@ -302,7 +293,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("excludes agents without a workspaceId", () => { @@ -316,14 +306,12 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), - knownAgentIds: new Set(["active-agent", "archived-agent"]), }; expect( @@ -341,7 +329,6 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, - knownAgentIds: agentVisibility.knownAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -354,12 +341,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -368,12 +353,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -382,26 +365,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), - knownAgentIds: new Set(["a", "b"]), - }; - expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); - }); - - it("returns false when knownAgentIds differ", () => { - const a = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), - }; - const b = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -410,12 +377,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 1f8f5c5ed4..399b786ef6 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,7 +6,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; - knownAgentIds: Set; } function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { @@ -24,13 +23,11 @@ export function deriveWorkspaceAgentVisibility(input: { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); - const knownAgentIds = new Set(); const agentsById = new Map([ ...(agentDetails?.entries() ?? []), ...(sessionAgents?.entries() ?? []), @@ -39,7 +36,6 @@ export function deriveWorkspaceAgentVisibility(input: { if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } - knownAgentIds.add(agent.id); if (!agent.archivedAt) { activeAgentIds.add(agent.id); const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined; @@ -48,14 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - for (const agent of agentDetails?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { - continue; - } - knownAgentIds.add(agent.id); - } - - return { activeAgentIds, autoOpenAgentIds, knownAgentIds }; + return { activeAgentIds, autoOpenAgentIds }; } export function buildWorkspaceTabSnapshot(input: { @@ -72,7 +61,6 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, - knownAgentIds: input.agentVisibility.knownAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -86,8 +74,7 @@ export function workspaceAgentVisibilityEqual( ): boolean { return ( setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && - setsEqual(a.knownAgentIds, b.knownAgentIds) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) ); } diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..0f58d94c62 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -69,7 +69,7 @@ test("loads archived records for history and active records with the interactive await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger }); await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger }); - expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]); + expect(resumeOptions).toEqual([{ purpose: "history" }, { purpose: "interactive" }]); } finally { await Promise.all([ manager.closeAgent(archivedId).catch(() => undefined), diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index f4d4069d26..3ea6413561 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -37,6 +37,7 @@ import type { AgentProvider, AgentPersistenceHandle, AgentRunOptions, + AgentResumeSessionOptions, AgentRunResult, AgentSession, AgentSessionConfig, @@ -5095,14 +5096,14 @@ test("archiveSnapshot clears persisted attention and normalizes running status", const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); expect(archivedRecord.archivedAt).toBe(archivedAt); - expect(archivedRecord.lastStatus).toBe("idle"); + expect(archivedRecord.lastStatus).toBe("closed"); expect(archivedRecord.requiresAttention).toBe(false); expect(archivedRecord.attentionReason).toBeNull(); expect(archivedRecord.attentionTimestamp).toBeNull(); const persisted = await storage.get(snapshot.id); expect(persisted?.archivedAt).toBe(archivedAt); - expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.lastStatus).toBe("closed"); expect(persisted?.requiresAttention).toBe(false); expect(persisted?.attentionReason).toBeNull(); expect(persisted?.attentionTimestamp).toBeNull(); @@ -9616,7 +9617,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () = } }); -test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => { +test("a stored-only archive waits for an in-flight resume and then closes it", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9640,16 +9641,17 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a }); await manager.closeAgent(agent.id); - const load = ensureUnarchivedAgentLoaded(agent.id, { + const load = ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger, }); await resumeStarted.promise; - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; - await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`); + await expect(load).resolves.toMatchObject({ id: agent.id }); expect(manager.getAgent(agent.id)).toBeNull(); expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String)); } finally { @@ -9659,7 +9661,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a } }); -test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => { +test("a stored-only archive closes a shared resume before a later protected load returns", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9694,8 +9696,9 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share agentStorage: storage, logger, }); - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; await sharedLoad; await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`); @@ -9933,7 +9936,7 @@ test("concurrent explicit closes tear down the runtime once", async () => { } }); -test("provider close failure still persists and emits a resumable closed agent", async () => { +test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new (class extends TestAgentClient { @@ -9953,12 +9956,10 @@ test("provider close failure still persists and emits a resumable closed agent", "00000000-0000-4000-8000-000000000217", { workspaceId: undefined }, ); - const closed = waitForAgentLifecycle(manager, created.id, "closed"); - await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed"); - await closed; + expect(manager.getAgent(created.id)?.session).toBe(created.session); const stored = await storage.get(created.id); - expect(stored).toMatchObject({ lastStatus: "closed" }); + expect(stored).toMatchObject({ lastStatus: "idle" }); expect(stored?.archivedAt).toBeFalsy(); await expect( @@ -10973,3 +10974,54 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls" expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled(); }); + +test("concurrent native restores run once before resuming the same agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const restoreStarted = deferred(); + const restoreAllowed = deferred(); + const operations: string[] = []; + const client = new (class extends TestAgentClient { + async unarchiveNativeSession(): Promise { + operations.push("restore"); + restoreStarted.resolve(); + await restoreAllowed.promise; + } + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + _launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, + ): Promise { + operations.push(`resume:${options?.purpose}`); + return super.resumeSession(handle, config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let agentId: string | undefined; + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await manager.archiveAgent(agentId); + const first = manager.unarchiveSnapshot(agentId); + await restoreStarted.promise; + const second = manager.unarchiveSnapshot(agentId); + const load = ensureAgentLoaded(agentId, { + agentManager: manager, + agentStorage: storage, + logger, + }); + restoreAllowed.resolve(); + await Promise.all([first, second, load]); + expect(operations).toEqual(["restore", "resume:interactive"]); + expect((await storage.get(agentId))?.archivedAt).toBeNull(); + expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId); + } finally { + restoreAllowed.resolve(); + if (agentId) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 70b4e62dbc..adbb25bf34 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1282,8 +1282,20 @@ export class AgentManager { }, resumeOptions?: AgentResumeSessionOptions, ): Promise { + const resolvedAgentId = validateAgentId( + agentId ?? this.idFactory(), + "resumeAgentFromPersistence", + ); return this.trackAgentRegistrationOperation( - this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions), + this.runLifecycleMutation(resolvedAgentId, () => + this.resumeAgentFromPersistenceInternal( + handle, + overrides, + resolvedAgentId, + options, + resumeOptions, + ), + ), ); } @@ -1317,6 +1329,12 @@ export class AgentManager { resolvedAgentId, ); + // Decide residency from durable state inside the lifecycle lane. A loader may + // have read the record before a queued archive or restore completed. + const record = this.registry ? await this.registry.get(resolvedAgentId) : null; + const currentResumeOptions = record + ? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) } + : resumeOptions; const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -1333,7 +1351,7 @@ export class AgentManager { undefined, { reason: "resume", - purpose: resumeOptions?.purpose ?? "interactive", + purpose: currentResumeOptions?.purpose ?? "interactive", workspaceId: options?.workspaceId ?? null, }, ); @@ -1342,7 +1360,7 @@ export class AgentManager { handle, providerLaunchConfig, launchContext, - resumeOptions, + currentResumeOptions, ); await this.requireExternalMcpSupport(session, storedConfig); return this.registerSession(session, storedConfig, resolvedAgentId, { @@ -1649,14 +1667,11 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + // Retain ownership until shutdown succeeds. A failed close may still own a + // native writer, so publishing a resumable closed snapshot would orphan it. + await agent.session.close(); this.cancelRunningProviderSubagents(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); - let closeError: unknown; - try { - await agent.session.close(); - } catch (error) { - closeError = error; - } let persistError: unknown; try { @@ -1674,9 +1689,6 @@ export class AgentManager { "agent.manager.close.complete", ); - if (closeError !== undefined) { - throw closeError; - } if (persistError !== undefined) { throw persistError; } @@ -1700,7 +1712,10 @@ export class AgentManager { return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId)); } - private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> { + private async archiveAgentUnlocked( + agentId: string, + requestedArchivedAt?: string, + ): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); if (!this.registry) { throw new Error("Agent storage is not configured"); @@ -1714,7 +1729,7 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found in storage after snapshot`); } - const { archivedAt } = await this.markRecordArchived(stored); + const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt); agent.updatedAt = new Date(archivedAt); await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); @@ -1764,14 +1779,16 @@ export class AgentManager { } else if (this.agents.has(currentChild.id)) { await this.archiveAgentUnlocked(currentChild.id); } else { - await this.archiveSnapshot(currentChild.id, new Date().toISOString()); + await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString()); } }); } } - private async markRecordArchived(record: StoredAgentRecord): Promise { - const archivedAt = new Date().toISOString(); + private async markRecordArchived( + record: StoredAgentRecord, + archivedAt = new Date().toISOString(), + ): Promise { const archivedRecord = await this.persistArchivedRecord(record, { archivedAt, updatedAt: archivedAt, @@ -2109,12 +2126,23 @@ export class AgentManager { } async archiveSnapshot(agentId: string, archivedAt: string): Promise { + return this.runLifecycleMutation(agentId, () => + this.archiveSnapshotUnlocked(agentId, archivedAt), + ); + } + + private async archiveSnapshotUnlocked( + agentId: string, + archivedAt: string, + ): Promise { const registry = this.requireRegistry(); - const liveAgent = this.getAgent(agentId); - if (liveAgent) { - await this.persistSnapshot(liveAgent, { - internal: liveAgent.internal, - }); + // A stored-only archive can have waited behind a persisted resume. Reuse the + // live archive transition so its newly acquired runtime is closed as well. + if (this.agents.has(agentId)) { + await this.archiveAgentUnlocked(agentId, archivedAt); + const archivedRecord = await registry.get(agentId); + if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`); + return archivedRecord; } const record = await registry.get(agentId); @@ -2126,14 +2154,8 @@ export class AgentManager { await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); - if (this.agents.has(agentId)) { - this.notifyAgentState(agentId); - } else { - this.discardRetainedAgentState(agentId); - if (!nextRecord.internal) { - this.dispatchStoredAgentState(nextRecord); - } - } + this.discardRetainedAgentState(agentId); + if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); await this.cascadeArchiveChildren(agentId); @@ -2144,6 +2166,15 @@ export class AgentManager { async unarchiveSnapshot( agentId: string, updates?: { workspaceId?: string; labels?: AgentLabelPatch }, + ): Promise { + return this.runLifecycleMutation(agentId, () => + this.unarchiveSnapshotUnlocked(agentId, updates), + ); + } + + private async unarchiveSnapshotUnlocked( + agentId: string, + updates?: { workspaceId?: string; labels?: AgentLabelPatch }, ): Promise { const registry = this.requireRegistry(); const record = await registry.get(agentId); @@ -2151,8 +2182,9 @@ export class AgentManager { return false; } - // Archived history may have loaded a runtime that still owns the native writer. - await this.closeAgent(agentId); + // Close and native restore share the lifecycle lane with persisted resume. + // No new history or interactive runtime can acquire the writer between them. + if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); await registry.upsert({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 15e7cfdf54..b86418814d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -111,7 +111,7 @@ interface CodexSessionTestAccess { ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): void; - loadPersistedHistory(): Promise; + loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; planModeEnabled: boolean; @@ -124,7 +124,7 @@ interface CodexClientLike { } type CodexTestSession = AgentSession & { - connected: boolean; + connectionState: "disconnected" | "history-ready" | "connected"; currentThreadId: string | null; activeForegroundTurnId: string | null; client: CodexClientLike | null; @@ -165,7 +165,7 @@ function createSession( options.goalsEnabled === true, options.autoReviewEnabled === true, ) as CodexTestSession; - session.connected = true; + session.connectionState = "connected"; session.currentThreadId = "test-thread"; session.activeForegroundTurnId = "test-turn"; return session; @@ -1435,7 +1435,7 @@ describe("Codex app-server provider", () => { purpose: "history", }); - expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]); + expect(threadRequests).toEqual(["thread/read"]); await session.close(); appServer.assertNoErrors(); }); @@ -3931,7 +3931,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -3985,7 +3985,7 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({ index: 0, @@ -4054,7 +4054,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4216,7 +4216,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4281,7 +4281,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4455,7 +4455,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4516,7 +4516,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 7123db17cc..5e2cb3795a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3371,7 +3371,7 @@ export class CodexAppServerAgentSession implements AgentSession { // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; private unpairedCompactionItemCompletions = 0; - private connected = false; + private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected"; private connectionPromise: Promise | null = null; private closed = false; private collaborationModes: Array<{ @@ -3444,7 +3444,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.closed) { throw this.createClosedError(); } - if (this.connected) return; + if (this.connectionState !== "disconnected") return; if (this.connectionPromise) { await this.connectionPromise; if (this.closed) { @@ -3468,6 +3468,11 @@ export class CodexAppServerAgentSession implements AgentSession { } private async establishConnection(): Promise { + if (this.initialResumePurpose === "history") { + await this.readArchivedHistory(); + this.connectionState = "history-ready"; + return; + } const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); if (this.closed) { @@ -3490,16 +3495,14 @@ export class CodexAppServerAgentSession implements AgentSession { await this.loadSkills(); if (this.currentThreadId) { - await this.ensureThreadLoaded({ - allowArchivedHistory: this.initialResumePurpose === "history", - }); - await this.loadPersistedHistory(); + await this.ensureThreadLoaded(); + await this.loadPersistedHistory(this.client); } if (this.closed) { throw this.createClosedError(); } - this.connected = true; + this.connectionState = "connected"; } catch (error) { try { if (this.client === client) { @@ -3550,7 +3553,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private handleUnexpectedTermination(error: Error): void { - this.connected = false; + this.connectionState = "disconnected"; const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null; this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn }); if (hasActiveRootTurn) { @@ -3782,9 +3785,20 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private async loadPersistedHistory(): Promise { - if (!this.client || !this.currentThreadId) return; - const client = this.client; + private async readArchivedHistory(): Promise { + const child = await this.spawnAppServer(); + const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + try { + await client.request("initialize", buildCodexAppServerInitializeParams()); + client.notify("initialized", {}); + await this.loadPersistedHistory(client); + } finally { + await client.dispose(); + } + } + + private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise { + if (!client || !this.currentThreadId) return; const threadId = this.currentThreadId; const history = await loadCodexThreadHistoryTimeline({ @@ -3865,9 +3879,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private async ensureThreadLoaded( - options: { allowArchivedHistory?: boolean } = {}, - ): Promise { + private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; const params: Record = { threadId: this.currentThreadId }; const developerInstructions = composeSystemPromptParts( @@ -3892,16 +3904,6 @@ export class CodexAppServerAgentSession implements AgentSession { } catch (error) { const threadId = this.currentThreadId; const message = error instanceof Error ? error.message : String(error); - if ( - options.allowArchivedHistory === true && - isArchivedCodexThreadResumeError(error, threadId) - ) { - this.logger.info( - { threadId }, - "Loading archived Codex thread history without resuming the native session", - ); - return; - } if (isArchivedCodexThreadResumeError(error, threadId)) { try { await this.client.request("thread/unarchive", { threadId }); @@ -3975,7 +3977,7 @@ export class CodexAppServerAgentSession implements AgentSession { return expandCodexCustomPrompt(parsed.body, args); } - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -4397,7 +4399,7 @@ export class CodexAppServerAgentSession implements AgentSession { async getRuntimeInfo(): Promise { if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo }; - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } if (!this.currentThreadId) { @@ -4770,7 +4772,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.persistedHistory = []; this.historyPending = false; - await this.loadPersistedHistory(); + await this.loadPersistedHistory(this.client); this.reconcileAsyncQuestionsAfterRewind(); }, }); @@ -4859,7 +4861,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async disposeClient(): Promise { const client = this.client; - this.connected = false; + this.connectionState = "disconnected"; this.currentTurnId = null; if (client) { await client.dispose(); @@ -4869,7 +4871,7 @@ export class CodexAppServerAgentSession implements AgentSession { async listCommands(): Promise { const prompts = await listCodexCustomPrompts(); - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); diff --git a/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..0d14f29930 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts @@ -0,0 +1,151 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { CodexAppServerClient } from "./codex/app-server-transport.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import type { AgentSession } from "../agent-sdk-types.js"; + +async function readNativeThreadPath(threadId: string): Promise { + const client = new CodexAppServerClient( + spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }), + createTestLogger(), + ); + try { + await client.request("initialize", { + clientInfo: { name: "paseo-archive-regression", version: "1.0.0" }, + }); + client.notify("initialized", {}); + const response = await client.request("thread/read", { threadId }); + return z.object({ thread: z.object({ path: z.string() }) }).parse(response).thread.path; + } finally { + await client.dispose(); + } +} + +// Real native processes and a real completion. The second case represents records +// archived by older Paseo versions whose best-effort native archive failed. +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([true, false])( + "Codex history releases its process and leaves native archive unchanged (native archived: %s)", + async (nativeArchived) => { + const cwd = await mkdtemp(path.join(tmpdir(), "codex-history-lifecycle-")); + const logger = createTestLogger(); + const provider = new CodexAppServerAgentClient(logger); + const spawned: ChildProcessWithoutNullStreams[] = []; + let active: AgentSession | undefined; + let history: CodexAppServerAgentSession | undefined; + try { + const catalog = await provider.fetchCatalog({ scope: "global", force: false }); + const model = catalog.models.find((entry) => entry.isDefault)!; + const config = { + provider: "codex", + cwd, + model: model.id, + modeId: "full-access", + thinkingOptionId: "low", + }; + const initialProcesses: ChildProcessWithoutNullStreams[] = []; + const initial = new CodexAppServerAgentSession(config, null, logger, async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + initialProcesses.push(child); + return child; + }); + active = initial; + await initial.connect(); + const reply = await active.run( + "Reply with exactly HISTORY_REMAINS_READ_ONLY. Do not use tools.", + ); + expect(reply.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + const handle = active.describePersistence()!; + await active.close(); + active = undefined; + expect( + initialProcesses.every((child) => child.exitCode !== null || child.signalCode !== null), + ).toBe(true); + if (nativeArchived) await provider.archiveNativeSession(handle); + + const nativePath = await readNativeThreadPath(handle.sessionId); + expect(nativePath.includes(`${path.sep}archived_sessions${path.sep}`)).toBe(nativeArchived); + history = new CodexAppServerAgentSession( + config, + handle, + logger, + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + await history.connect(); + const text: string[] = []; + for await (const event of history.streamHistory()) { + if (event.type === "timeline" && event.item.type === "assistant_message") + text.push(event.item.text); + } + expect(text.join("\n")).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(spawned).toHaveLength(1); + expect( + spawned.filter((child) => child.exitCode === null && child.signalCode === null), + ).toEqual([]); + + expect(await readNativeThreadPath(handle.sessionId)).toBe(nativePath); + + // Native unarchive is possible after reading history; history held no writer. + await provider.unarchiveNativeSession(handle); + active = await provider.resumeSession(handle, config); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + const followUp = await active.run( + "Repeat exactly your previous assistant reply from this conversation. Do not use tools.", + ); + expect(followUp.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + } finally { + await history?.close(); + await active?.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 120_000, +); + +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1")( + "a failed Codex history read releases its temporary process", + async () => { + const spawned: ChildProcessWithoutNullStreams[] = []; + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "00000000-0000-4000-8000-000000000001" }, + createTestLogger(), + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + try { + await expect(session.connect()).rejects.toThrow(); + expect(spawned).toHaveLength(1); + expect(spawned.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe( + true, + ); + } finally { + await session.close(); + } + }, + 30_000, +);