From c9e56e1eb9b7dae0b7cea669ebfb50e0b4342e3c Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:36:00 +0000 Subject: [PATCH] fix(server): preserve each refresh caller workspace recovery --- .../concurrent-agent-refresh.e2e.test.ts | 62 ++++++++++++++++++- packages/server/src/server/session.ts | 2 +- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts b/packages/server/src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts index 0f09c730aa..189b569c07 100644 --- a/packages/server/src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import path from "node:path"; import { afterEach, expect, test } from "vitest"; @@ -60,17 +61,19 @@ function heldHistoryProvider() { return { provider, historyStarted, releaseHistory, resumes: () => resumes }; } -async function setup(provider: AgentClient, agentId?: string) { +async function setup(provider: AgentClient, agentId?: string, appVersions = ["0.14.0", "0.14.0"]) { const cwd = mkdtempSync(path.join(artifactRoot, "concurrent-refresh-")); const daemon = await createTestPaseoDaemon({ agentClients: { claude: provider } }); - const clients = [0, 1].map(() => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` })); + const clients = appVersions.map( + (appVersion) => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion }), + ); resources.push({ daemon, clients, cwd }); await Promise.all(clients.map((client) => client.connect())); await Promise.all(clients.map((client) => client.fetchAgents({ subscribe: {} }))); const agent = await daemon.daemon.agentManager.createAgent({ provider: "claude", cwd }, agentId, { workspaceId: undefined, }); - return { clients, agent }; + return { clients, agent, cwd }; } test("refreshes from separate connections share the reload until history is ready", async () => { @@ -149,3 +152,56 @@ test("concurrent refreshes share a failure and a later retry starts a new reload await expect(clients[0].refreshAgent(agent.id)).resolves.toMatchObject({ agentId: agent.id }); expect(resumes).toBe(2); }); + +test("a legacy caller restores its workspace when joining a modern refresh", async () => { + const provider = heldHistoryProvider(); + const { + clients: [modern, legacy], + cwd, + } = await setup(provider.provider, undefined, ["0.1.105", "0.1.104"]); + // An independent repository prevents archive from owning the enclosing test checkout. + execFileSync("git", ["init", "-b", "main", cwd], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + { cwd, stdio: "pipe" }, + ); + const created = await modern.createWorkspace({ source: { kind: "directory", path: cwd } }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + const agent = await modern.createAgent({ provider: "claude", cwd, workspaceId: workspace.id }); + await modern.archiveWorkspace(workspace.id); + expect((await modern.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id)).toBe( + false, + ); + const first = modern.refreshAgent(agent.id); + await provider.historyStarted.promise; + expect((await modern.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id)).toBe( + false, + ); + const joining = legacy.refreshAgent(agent.id); + const outcomes = Promise.allSettled([first, joining]); + try { + await expect + .poll( + async () => + (await legacy.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id), + { timeout: 5000 }, + ) + .toBe(true); + } finally { + provider.releaseHistory.resolve(); + } + expect((await outcomes).map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 5bfe83cb09..5e5b3b3c6d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4761,6 +4761,7 @@ export class Session { this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`); try { + await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId); // Runtime replacement is serialized, but the reply also needs provider history. // Another replacement during hydration can remove the agent before that reply. // Share the whole reload and hydration across connections, including failures. @@ -4819,7 +4820,6 @@ export class Session { } private async refreshAgentFromPersistence(agentId: string): Promise { - await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId); await unarchiveAgentState(this.agentStorage, this.agentManager, agentId); let snapshot: ManagedAgent; const existing = this.agentManager.getAgent(agentId);