From d1450ad1b771f510fca9169cb23e57882981cd79 Mon Sep 17 00:00:00 2001 From: Darrell Date: Tue, 3 Nov 2026 11:49:15 -0500 Subject: [PATCH] fix(server): keep the workspace when resuming an agent by handle resume_agent_request matched the stored agent but dropped its workspace id, so the resumed agent had no PASEO_WORKSPACE_ID and no workspace. Pass the matched record's workspace id into the resume. resumeAgentFromPersistence now resolves the workspace once and gives it to both the launch env and the registered agent, so a caller that omits it no longer erases the stored value on the next snapshot. Co-Authored-By: Claude Opus 5.5 --- .../src/server/agent/agent-manager.test.ts | 33 ++++++++++------- .../server/src/server/agent/agent-manager.ts | 10 +++--- .../src/server/daemon-client.e2e.test.ts | 36 +++++++++++++++++++ packages/server/src/server/session.ts | 7 +++- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2721cd67f9..02e1798789 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -3333,22 +3333,29 @@ test("createAgent and resume give the agent its workspace id", async () => { PASEO_WORKSPACE_ID: "ws-agent-home", }); - // A daemon restart resumes from the stored record, without a workspace id in the call. - await manager.closeAgent(snapshot.id); - await manager.resumeAgentFromPersistence( - { - provider: "codex", - sessionId: "workspace-env-session", - metadata: { provider: "codex", cwd: workdir }, - }, - { cwd: workdir }, - snapshot.id, - ); - expect(client.resumeLaunchContext?.env).toEqual({ + // The stored record supplies the workspace when the caller doesn't. + const handle: AgentPersistenceHandle = { + provider: "codex", + sessionId: "workspace-env-session", + metadata: { provider: "codex", cwd: workdir }, + }; + const expectedEnv = { PASEO_AGENT_ID: snapshot.id, PASEO_AGENT_CWD: workdir, PASEO_WORKSPACE_ID: "ws-agent-home", - }); + }; + + await manager.closeAgent(snapshot.id); + const resumed = await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id); + expect(client.resumeLaunchContext?.env).toEqual(expectedEnv); + expect(resumed.workspaceId).toBe("ws-agent-home"); + expect((await storage.get(snapshot.id))?.workspaceId).toBe("ws-agent-home"); + + // Each restart reads what the previous resume stored. + client.resumeLaunchContext = undefined; + await manager.closeAgent(snapshot.id); + await manager.resumeAgentFromPersistence(handle, { cwd: workdir }, snapshot.id); + expect(client.resumeLaunchContext?.env).toEqual(expectedEnv); }); test("createAgent passes persistSession to provider create options", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c008999944..b47e12e558 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -683,15 +683,15 @@ function agentIdentityEnv({ }; } -// A restart resumes from the stored record, with no workspace id in the call. +// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace. function resolveResumeWorkspaceId({ options, record, }: { options: { workspaceId?: string } | undefined; record: Pick | null; -}): string | null { - return options?.workspaceId ?? record?.workspaceId ?? null; +}): string | undefined { + return options?.workspaceId ?? record?.workspaceId; } function validateAgentId(agentId: string, source: string): string { @@ -1601,6 +1601,7 @@ export class AgentManager { const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions; const launchEnv = record?.launchEnv; + const workspaceId = resolveResumeWorkspaceId({ options, record }); const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, @@ -1623,7 +1624,7 @@ export class AgentManager { { reason: "resume", purpose, - workspaceId: resolveResumeWorkspaceId({ options, record }), + workspaceId, title: record?.title ?? mergedConfig.title ?? null, labels: record?.labels ?? options?.labels ?? {}, }, @@ -1639,6 +1640,7 @@ export class AgentManager { await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, + workspaceId, launchEnv, persistence: handle, restoring: true, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index a442dfe6a9..701a7f6108 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -18,6 +18,7 @@ import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-config import { parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js"; import type { AgentClient, + AgentLaunchContext, AgentPersistenceHandle, AgentRunResult, AgentSession, @@ -324,6 +325,7 @@ class RecordingMcpResumeClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities; readonly resumeOverrides: Array | undefined> = []; + readonly resumeLaunchContexts: Array = []; private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex; constructor() { @@ -341,8 +343,10 @@ class RecordingMcpResumeClient implements AgentClient { resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise { this.resumeOverrides.push(overrides); + this.resumeLaunchContexts.push(launchContext); return this.inner.resumeSession(handle, overrides); } @@ -1034,6 +1038,38 @@ test("resume_agent rehydrates the newest private MCP config from a redacted pers } }); +test("resume_agent keeps the workspace of the agent it matches", async () => { + const cwd = tmpCwd(); + const provider = new RecordingMcpResumeClient(); + const localCtx = await createDaemonTestContext({ + agentClients: { codex: provider }, + }); + + try { + const opened = await localCtx.client.openProject(cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? "Workspace did not open"); + } + const workspaceId = opened.workspace.id; + const created = await localCtx.client.createAgent({ + config: { provider: "codex", cwd }, + workspaceId, + }); + if (!created.persistence) { + throw new Error("Expected a persistence handle"); + } + + await localCtx.client.archiveAgent(created.id); + const resumed = await localCtx.client.resumeAgent(created.persistence); + + expect(provider.resumeLaunchContexts[0]?.env?.PASEO_WORKSPACE_ID).toBe(workspaceId); + expect(resumed.workspaceId).toBe(workspaceId); + } finally { + await localCtx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("resume_agent restores archive state when an MCP-capable provider returns an unsupported session", async () => { const cwd = tmpCwd(); const provider = new RejectingMcpResumeClient(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 83f030d971..55d6c58401 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4856,7 +4856,12 @@ export class Session { : overrides; let snapshot: ManagedAgent; try { - snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides); + snapshot = await this.agentManager.resumeAgentFromPersistence( + handle, + effectiveOverrides, + undefined, + { workspaceId: matched?.record.workspaceId }, + ); } catch (error) { if (matched?.didUnarchive && matched.originalArchivedAt) { await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt);