Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 95752e54e6 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.
2026-07-22 12:51:08 +00:00
7 changed files with 90 additions and 48 deletions
@@ -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" },
+35 -5
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,
+51
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 });
@@ -7,20 +7,6 @@ function cells(text: string): TerminalState["grid"][number] {
}
describe("renderTerminalSnapshotToAnsi", () => {
it("preserves adjacent wide characters", () => {
const state: TerminalState = {
rows: 1,
cols: 4,
scrollback: [],
grid: [[{ char: "\u4e2d" }, { char: "" }, { char: "\u6587" }, { char: "" }]],
cursor: { row: 0, col: 4 },
};
const ansi = renderTerminalSnapshotToAnsi(state);
expect(ansi).toContain("\u4e2d\u6587");
});
it("renders soft-wrapped rows as one contiguous logical line when wrap flags are present", () => {
// The server soft-wrapped one logical line "ABCDEFGHIJKLMNOP" at 10 cols into
// two grid rows. gridWrapped[0] = true marks row 0 as continuing into row 1.
+1 -4
View File
@@ -87,15 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
if (cell.char === "") {
continue;
}
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
previousStyle = nextStyle;
}
output.push(cell.char);
output.push(cell.char || " ");
}
if (!terminalStylesEqual(previousStyle, DEFAULT_STYLE)) {
@@ -1626,29 +1626,6 @@ describe("capture", () => {
expect(capture.totalLines).toBeGreaterThan(0);
}, 15000);
test("preserves adjacent CJK characters in captured output", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
const created = await createTerminalInWorkspace(ctx.client, { cwd });
const terminalId = created.terminal!.id;
await ctx.client.subscribeTerminal(terminalId);
ctx.client.sendTerminalInput(terminalId, {
type: "input",
data: `node -e "process.stdout.write('START\\u4e2d\\u6587END\\n')"\r`,
});
await waitForTerminalOutput(
ctx.client,
terminalId,
(text) => text.includes("START\u4e2d\u6587END"),
15000,
);
const capture = await ctx.client.captureTerminal(terminalId);
expect(capture.lines).toContain("START\u4e2d\u6587END");
}, 15000);
test("captures with start/end line range", async () => {
const cwd = tmpCwd();
tempDirs.push(cwd);
+2 -2
View File
@@ -494,7 +494,7 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
return {
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
char: cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,
@@ -553,7 +553,7 @@ function extractScrollback(
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
rowCells.push({
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
char: cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,