fix(cli): clean up failed run workspaces

Agent creation errors left workspaces created by paseo run behind. Roll back only the workspace owned by that run while preserving the original failure.
This commit is contained in:
Mohamed Boudra
2026-07-22 12:48:05 +00:00
parent 68993b7ab3
commit 95752e54e6
3 changed files with 87 additions and 5 deletions

View File

@@ -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" },

View File

@@ -504,6 +504,32 @@ async function connectToDaemonOrThrow(
interface RunWorkspace {
id?: string;
cwd: string;
createdForRun: boolean;
}
type CreateRunAgentInput = Parameters<ConnectedDaemonClient["createAgent"]>[0];
async function createAgentInRunWorkspace(
client: ConnectedDaemonClient,
workspace: RunWorkspace,
input: CreateRunAgentInput,
): Promise<AgentSnapshotPayload> {
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 <id> (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<string> => {
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,

View File

@@ -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 });