mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
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:
co-authored by
Claude Opus 5.5
Darrell
parent
ea30d2616a
commit
1efcb31a6c
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user