fix(server): preserve each refresh caller workspace recovery

This commit is contained in:
paseo-bot[bot]
2026-10-31 10:36:00 +00:00
parent 3274d67959
commit c9e56e1eb9
2 changed files with 60 additions and 4 deletions
@@ -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"]);
});
+1 -1
View File
@@ -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<ManagedAgent> {
await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId);
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);