Give agents their own workspace identity through create and resume (#8939)

* fix(server): give agents their workspace id as PASEO_WORKSPACE_ID

Agents got PASEO_AGENT_ID and PASEO_AGENT_CWD but not their workspace id, so an
agent couldn't rename or archive its own workspace, or name it to create_agent,
when several workspaces share its cwd. The launch env now sets
PASEO_WORKSPACE_ID whenever the agent has a workspace, including on resume after
a daemon restart, where the id now comes from the stored record. OpenCode treats
it as a per-session key, so it doesn't force a dedicated server.

Refs #5098

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* test(server): exercise current daemon creation and permission contracts

The workspace resume regression shares this suite with creation checks that still waited for legacy status frames and permission listeners without an owned subscription. Verify the returned error snapshot and acquire event demand so the complete suite can validate workspace identity changes.

Co-authored-by: Darrell <DT@Terastar.biz>

---------

Co-authored-by: Darrell <DT@Terastar.biz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-06 18:06:59 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 Darrell
parent ea30d2616a
commit 1efcb31a6c
11 changed files with 188 additions and 64 deletions
@@ -3286,6 +3286,78 @@ test.each([
},
);
test("createAgent and resume give the agent its workspace id", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-workspace-env-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class CaptureClient extends TestAgentClient {
createLaunchContext: AgentLaunchContext | undefined;
resumeLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.createLaunchContext = launchContext;
return new TestAgentSession(config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeLaunchContext = launchContext;
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
return new TestAgentSession({ ...metadata, ...overrides, provider: "codex", cwd: workdir });
}
}
const client = new CaptureClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000140",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "ws-agent-home",
});
expect(client.createLaunchContext?.env).toEqual({
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-agent-home",
});
// 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 () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -4731,6 +4803,7 @@ test("importProviderSession imports the selected session without listing and pub
env: {
PASEO_AGENT_ID: imported.id,
PASEO_AGENT_CWD: workdir,
PASEO_WORKSPACE_ID: "ws-imported",
},
});
expect(imported.lifecycle).toBe("idle");
@@ -664,6 +664,36 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
agentId,
cwd,
opening,
}: {
agentId: string;
cwd: string;
opening: { workspaceId?: string | null } | undefined;
}): Record<string, string> {
const workspaceId = opening?.workspaceId;
return {
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
};
}
// 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<StoredAgentRecord, "workspaceId"> | null;
}): string | undefined {
return options?.workspaceId ?? record?.workspaceId;
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -1571,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,
@@ -1593,7 +1624,7 @@ export class AgentManager {
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
@@ -1609,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,
@@ -5774,8 +5806,7 @@ export class AgentManager {
mcpServerOwners,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...agentIdentityEnv({ agentId, cwd, opening }),
},
};
const useNativeTools =
@@ -349,7 +349,11 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
providerOptions: OpenCodeProviderOptions;
};
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
const OPENCODE_SESSION_ENV_KEYS = new Set([
"PASEO_AGENT_ID",
"PASEO_AGENT_CWD",
"PASEO_WORKSPACE_ID",
]);
function requiresDedicatedOpenCodeServer(
config: OpenCodeAgentConfig,
@@ -24,7 +24,14 @@ describe("OpenCode v2 helper isolation", () => {
expect(
requiresDedicatedV2Server(
{ provider: "opencode", cwd: "/tmp/project" },
{ agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } },
{
agentId: "agent",
env: {
PASEO_AGENT_ID: "agent",
PASEO_AGENT_CWD: "/tmp/project",
PASEO_WORKSPACE_ID: "ws-1",
},
},
),
).toBe(false);
});
@@ -72,7 +72,7 @@ export async function applyResumeOverrides(
// it must not force a dedicated server. Custom MCP is location-scoped, and any
// other env key belongs to the process, so both need isolation. Mirrors the v1
// adapter's `requiresDedicatedOpenCodeServer`.
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD", "PASEO_WORKSPACE_ID"]);
export function requiresDedicatedV2Server(
config: AgentSessionConfig,
@@ -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<Partial<AgentSessionConfig> | undefined> = [];
readonly resumeLaunchContexts: Array<AgentLaunchContext | undefined> = [];
private readonly inner = createTestAgentClients({ supportsMcpServers: true }).codex;
constructor() {
@@ -341,8 +343,10 @@ class RecordingMcpResumeClient implements AgentClient {
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeOverrides.push(overrides);
this.resumeLaunchContexts.push(launchContext);
return this.inner.resumeSession(handle, overrides);
}
@@ -402,7 +406,7 @@ class RejectingMcpResumeClient implements AgentClient {
}
}
test("createAgent fails when the initial turn cannot start", async () => {
test("createAgent preserves the error snapshot when the initial turn cannot start", async () => {
const testAgent = new StubAgentClient({
sessionId: "start-turn-failure-session",
supportsStreaming: false,
@@ -421,16 +425,18 @@ test("createAgent fails when the initial turn cannot start", async () => {
await client.connect();
await client.fetchAgents({ subscribe: {} });
await expect(
client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
}),
).rejects.toThrow("Initial turn failed to start");
const created = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Start failure agent",
modeId: "full-access",
model: "gpt-5.4-mini",
initialPrompt: "Run exactly: sleep 30",
});
expect(created.status).toBe("error");
expect(created.lastError).toBe("Initial turn failed to start");
const fetched = await client.fetchAgent({ agentId: created.id });
expect(fetched?.agent.lastError).toBe("Initial turn failed to start");
} finally {
await client.close();
await daemon.close();
@@ -1034,6 +1040,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();
@@ -1369,27 +1407,6 @@ test("creates agent and exercises lifecycle", async () => {
});
const createRequestId = `create-${Date.now()}`;
const createdStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
agentId?: string;
requestId?: string;
};
if (payload.status !== "agent_created") {
return;
}
if (payload.requestId !== createRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
const agent = await ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd,
@@ -1404,30 +1421,8 @@ test("creates agent and exercises lifecycle", async () => {
const agentUpdate = await agentUpdatePromise;
expect(agentUpdate.payload.agent.id).toBe(agent.id);
const createdStatus = await createdStatusPromise;
expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id);
const failRequestId = `fail-${Date.now()}`;
const failedStatusPromise = waitForSignal(15000, (resolve) => {
const unsubscribe = ctx.client.on("status", (message) => {
if (message.type !== "status") {
return;
}
const payload = message.payload as {
status?: string;
requestId?: string;
};
if (payload.status !== "agent_create_failed") {
return;
}
if (payload.requestId !== failRequestId) {
return;
}
resolve(message);
});
return unsubscribe;
});
await expect(
ctx.client.createAgent({
...getFullAccessConfig("codex"),
@@ -1436,7 +1431,6 @@ test("creates agent and exercises lifecycle", async () => {
requestId: failRequestId,
}),
).rejects.toThrow("Working directory does not exist");
await failedStatusPromise;
let sawRefresh = false;
const unsubscribe = ctx.client.subscribe((event) => {
@@ -1615,6 +1609,11 @@ test("creates agent and exercises lifecycle", async () => {
}, 300000);
test("handles permission flow", async () => {
const permissionEvents = ctx.client.observeEvents([
"agent_permission_request",
"agent_permission_resolved",
]);
await permissionEvents.ready;
const cwd = tmpCwd();
const filePath = path.join(cwd, "permission.txt");
@@ -1683,6 +1682,7 @@ test("handles permission flow", async () => {
// Prevent unhandled rejections if the test fails before promises resolve.
await permissionRequestPromise.catch(() => {});
await permissionResolvedPromise.catch(() => {});
await permissionEvents.release();
await ctx.client.deleteAgent(agent.id);
rmSync(cwd, { recursive: true, force: true });
}
+6 -1
View File
@@ -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);
+2
View File
@@ -211,6 +211,8 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
Inside an agent session, `PASEO_WORKSPACE_ID` holds the agent's own workspace ID, so an agent can run `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` or `paseo workspace archive "$PASEO_WORKSPACE_ID"` even when other workspaces share its directory.
Add `--forge <name>` to PR checkout when Paseo cannot infer the forge from the source checkout. See [Git worktrees](/docs/worktrees) for setup hooks and services.
## Terminals
+2
View File
@@ -202,6 +202,8 @@ paseo workspace rename <workspace-id> --reset # back to the branch or director
paseo workspace archive <workspace-id>
```
在 Agent 会话中,`PASEO_WORKSPACE_ID` 保存该 Agent 自己的工作区 ID。即使其他工作区共享同一个目录,Agent 也可以运行 `paseo workspace rename "$PASEO_WORKSPACE_ID" "Auth rework"` 或 `paseo workspace archive "$PASEO_WORKSPACE_ID"`。
当 Paseo 无法从源检出推断代码托管平台时,为 PR 检出添加 `--forge <name>`。setup 钩子和服务见 [Git worktree](/docs/worktrees)。
## 终端 {#terminals}
+1 -1
View File
@@ -598,7 +598,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ open provider session and save agent configuration
```
+1 -1
View File
@@ -581,7 +581,7 @@ Creation request
→ resolve defaults and validate provider configuration
→ derive launch configuration with Paseo runtime tools and daemon prompt
→ agent.open.before hooks (same ordering; env and plugin-local tools and MCP servers)
→ set PASEO_AGENT_ID and PASEO_AGENT_CWD
→ set PASEO_AGENT_ID, PASEO_AGENT_CWD, and PASEO_WORKSPACE_ID when the agent has a workspace
→ open provider session and save agent configuration
```