diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 755638d4d..5f6397af3 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -26,6 +26,7 @@ describe("existing run workspace resolution", () => { await expect(resolveExistingRunWorkspace({ fetchWorkspaces }, "workspace-2")).resolves.toEqual({ id: "workspace-2", cwd: "/workspace/two", + createdForRun: false, }); expect(fetchWorkspaces).toHaveBeenCalledWith({ filter: { query: "workspace-2" }, diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 14e494c56..7607bfe61 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -504,6 +504,32 @@ async function connectToDaemonOrThrow( interface RunWorkspace { id?: string; cwd: string; + createdForRun: boolean; +} + +type CreateRunAgentInput = Parameters[0]; + +async function createAgentInRunWorkspace( + client: ConnectedDaemonClient, + workspace: RunWorkspace, + input: CreateRunAgentInput, +): Promise { + try { + return await client.createAgent(input); + } catch (error) { + if (workspace.createdForRun && workspace.id) { + try { + const archived = await client.archiveWorkspace(workspace.id); + if (archived.error) { + console.error(`Warning: failed to clean up workspace ${workspace.id}: ${archived.error}`); + } + } catch (cleanupError) { + const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + console.error(`Warning: failed to clean up workspace ${workspace.id}: ${message}`); + } + } + throw error; + } } export interface RunWorkspaceLookupClient { @@ -523,7 +549,7 @@ export async function resolveExistingRunWorkspace( }); const workspace = result.entries.find((entry) => entry.id === workspaceId); if (workspace) { - return { id: workspace.id, cwd: workspace.workspaceDirectory }; + return { id: workspace.id, cwd: workspace.workspaceDirectory, createdForRun: false }; } throw { @@ -551,7 +577,7 @@ async function resolveRunWorkspace( } if (!newWorkspace && resolveRunCallerAgentId()) { - return { cwd }; + return { cwd, createdForRun: false }; } const ambientWorkspaceId = newWorkspace ? undefined : process.env.PASEO_WORKSPACE_ID?.trim(); @@ -578,7 +604,11 @@ async function resolveRunWorkspace( console.error( "Tip: pass --workspace (or set PASEO_WORKSPACE_ID) to run in an existing workspace.", ); - return { id: result.workspace.id, cwd: result.workspace.workspaceDirectory ?? cwd }; + return { + id: result.workspace.id, + cwd: result.workspace.workspaceDirectory ?? cwd, + createdForRun: true, + }; } export async function runRunCommand( @@ -627,7 +657,7 @@ export async function runRunCommand( const callStructuredTurn = async (structuredPrompt: string): Promise => { if (!structuredAgent) { - structuredAgent = await client.createAgent({ + structuredAgent = await createAgentInRunWorkspace(client, workspace, { provider: resolvedProviderModel.provider, cwd: runCwd, workspaceId, @@ -698,7 +728,7 @@ export async function runRunCommand( } // Create the agent - const agent = await client.createAgent({ + const agent = await createAgentInRunWorkspace(client, workspace, { provider: resolvedProviderModel.provider, cwd: runCwd, workspaceId, diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 0af2a6ec7..045030bcf 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -25,6 +25,7 @@ import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; $.verbose = false; @@ -268,6 +269,56 @@ try { assert(output.includes("unknown option"), "should report unknown option for --isolation"); console.log("✓ run --isolation is rejected\n"); } + + // Test 17: failed agent creation does not leave the run's workspace behind + { + console.log("Test 17: failed agent creation cleans up its workspace"); + const ctx = await createE2ETestContext({ timeout: 45000 }); + try { + await $({ cwd: ctx.workDir })`git init -b main`; + await $({ cwd: ctx.workDir })`git config user.email test@getpaseo.local`; + await $({ cwd: ctx.workDir })`git config user.name "Paseo Test"`; + await $({ cwd: ctx.workDir })`git commit --allow-empty -m initial`; + + const before = await ctx.paseo(["workspace", "ls", "--json"]); + assert.strictEqual(before.exitCode, 0, before.stderr); + + const result = await ctx.paseo( + [ + "run", + "--provider", + "claude", + "--mode", + "bypass", + "--new-workspace", + "worktree", + "--worktree-mode", + "branch-off", + "--new-branch", + "failed-run-workspace", + "--base", + "main", + "--background", + "do something", + ], + { + env: { PASEO_AGENT_ID: "", PASEO_WORKSPACE_ID: "" }, + }, + ); + assert.notStrictEqual(result.exitCode, 0, "invalid mode should reject agent creation"); + assert( + `${result.stdout}\n${result.stderr}`.includes("Invalid mode 'bypass'"), + `failure should reach provider mode validation\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + + const after = await ctx.paseo(["workspace", "ls", "--json"]); + assert.strictEqual(after.exitCode, 0, after.stderr); + assert.deepStrictEqual(JSON.parse(after.stdout), JSON.parse(before.stdout)); + } finally { + await ctx.stop(); + } + console.log("✓ failed agent creation cleans up its workspace\n"); + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true });