Skip to content

Commit f9a8059

Browse files
committed
🤖 fix: rollback failed task spawns
If persisting the queued prompt or starting the child stream fails, remove the newly-created task workspace from config and best-effort delete its worktree + session dir to avoid orphaned descendants blocking future task creation. Add a regression test for the sendMessage failure path. Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `codex cli` • Model: `gpt-5.2` • Thinking: `xhigh`_ <!-- mux-attribution: model=gpt-5.2 thinking=xhigh --> Change-Id: I77552838252872e4d80f26b8259f8892831ceb59
1 parent d94c312 commit f9a8059

File tree

2 files changed

+163
-0
lines changed

2 files changed

+163
-0
lines changed

src/node/services/taskService.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,123 @@ describe("TaskService", () => {
301301
expect(started?.taskStatus).toBe("running");
302302
}, 20_000);
303303

304+
test("rolls back created workspace when initial sendMessage fails", async () => {
305+
const config = new Config(rootDir);
306+
await fsPromises.mkdir(config.srcDir, { recursive: true });
307+
308+
const configWithStableId = config as unknown as { generateStableId: () => string };
309+
configWithStableId.generateStableId = () => "aaaaaaaaaa";
310+
311+
const projectPath = path.join(rootDir, "repo");
312+
await fsPromises.mkdir(projectPath, { recursive: true });
313+
initGitRepo(projectPath);
314+
315+
const runtimeConfig = { type: "worktree" as const, srcBaseDir: config.srcDir };
316+
const runtime = createRuntime(runtimeConfig, { projectPath });
317+
const initLogger = createNullInitLogger();
318+
319+
const parentName = "parent";
320+
const parentCreate = await runtime.createWorkspace({
321+
projectPath,
322+
branchName: parentName,
323+
trunkBranch: "main",
324+
directoryName: parentName,
325+
initLogger,
326+
});
327+
expect(parentCreate.success).toBe(true);
328+
329+
const parentId = "1111111111";
330+
const parentPath = runtime.getWorkspacePath(projectPath, parentName);
331+
332+
await config.saveConfig({
333+
projects: new Map([
334+
[
335+
projectPath,
336+
{
337+
workspaces: [
338+
{
339+
path: parentPath,
340+
id: parentId,
341+
name: parentName,
342+
createdAt: new Date().toISOString(),
343+
runtimeConfig,
344+
},
345+
],
346+
},
347+
],
348+
]),
349+
taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 },
350+
});
351+
352+
const historyService = new HistoryService(config);
353+
const partialService = new PartialService(config, historyService);
354+
355+
const aiService: AIService = {
356+
isStreaming: mock(() => false),
357+
getWorkspaceMetadata: mock(
358+
async (workspaceId: string): Promise<Result<WorkspaceMetadata>> => {
359+
const all = await config.getAllWorkspaceMetadata();
360+
const found = all.find((m) => m.id === workspaceId);
361+
return found ? Ok(found) : Err("not found");
362+
}
363+
),
364+
on: mock(() => undefined),
365+
off: mock(() => undefined),
366+
} as unknown as AIService;
367+
368+
const sendMessage = mock(() => Promise.resolve(Err("send failed")));
369+
const resumeStream = mock(() => Promise.resolve(Ok(undefined)));
370+
const remove = mock(() => Promise.resolve(Ok(undefined)));
371+
const emit = mock(() => true);
372+
373+
const workspaceService: WorkspaceService = {
374+
sendMessage,
375+
resumeStream,
376+
remove,
377+
emit,
378+
} as unknown as WorkspaceService;
379+
380+
const initStateManager: InitStateManager = {
381+
startInit: mock(() => undefined),
382+
appendOutput: mock(() => undefined),
383+
endInit: mock(() => Promise.resolve()),
384+
} as unknown as InitStateManager;
385+
386+
const taskService = new TaskService(
387+
config,
388+
historyService,
389+
partialService,
390+
aiService,
391+
workspaceService,
392+
initStateManager
393+
);
394+
395+
const created = await taskService.create({
396+
parentWorkspaceId: parentId,
397+
kind: "agent",
398+
agentType: "explore",
399+
prompt: "do the thing",
400+
});
401+
402+
expect(created.success).toBe(false);
403+
404+
const postCfg = config.loadConfigOrDefault();
405+
const stillExists = Array.from(postCfg.projects.values())
406+
.flatMap((p) => p.workspaces)
407+
.some((w) => w.id === "aaaaaaaaaa");
408+
expect(stillExists).toBe(false);
409+
410+
const workspaceName = "agent_explore_aaaaaaaaaa";
411+
const workspacePath = runtime.getWorkspacePath(projectPath, workspaceName);
412+
let workspacePathExists = true;
413+
try {
414+
await fsPromises.access(workspacePath);
415+
} catch {
416+
workspacePathExists = false;
417+
}
418+
expect(workspacePathExists).toBe(false);
419+
}, 20_000);
420+
304421
test("agent_report posts report to parent, finalizes pending task tool output, and triggers cleanup", async () => {
305422
const config = new Config(rootDir);
306423

src/node/services/taskService.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from "node:assert/strict";
2+
import * as fsPromises from "fs/promises";
23

34
import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex";
45
import type { Config, Workspace as WorkspaceConfigEntry } from "@/node/config";
@@ -334,6 +335,7 @@ export class TaskService {
334335

335336
const appendResult = await this.historyService.appendToHistory(taskId, userMessage);
336337
if (!appendResult.success) {
338+
await this.rollbackFailedTaskCreate(runtime, parentMeta.projectPath, workspaceName, taskId);
337339
return Err(`Task.create: failed to persist queued prompt (${appendResult.error})`);
338340
}
339341

@@ -353,12 +355,56 @@ export class TaskService {
353355
typeof sendResult.error === "string"
354356
? sendResult.error
355357
: formatSendMessageError(sendResult.error).message;
358+
await this.rollbackFailedTaskCreate(runtime, parentMeta.projectPath, workspaceName, taskId);
356359
return Err(message);
357360
}
358361

359362
return Ok({ taskId, kind: "agent", status: "running" });
360363
}
361364

365+
private async rollbackFailedTaskCreate(
366+
runtime: ReturnType<typeof createRuntime>,
367+
projectPath: string,
368+
workspaceName: string,
369+
taskId: string
370+
): Promise<void> {
371+
try {
372+
await this.config.removeWorkspace(taskId);
373+
} catch (error: unknown) {
374+
log.error("Task.create rollback: failed to remove workspace from config", {
375+
taskId,
376+
error: error instanceof Error ? error.message : String(error),
377+
});
378+
}
379+
380+
this.workspaceService.emit("metadata", { workspaceId: taskId, metadata: null });
381+
382+
try {
383+
const deleteResult = await runtime.deleteWorkspace(projectPath, workspaceName, true);
384+
if (!deleteResult.success) {
385+
log.error("Task.create rollback: failed to delete workspace", {
386+
taskId,
387+
error: deleteResult.error,
388+
});
389+
}
390+
} catch (error: unknown) {
391+
log.error("Task.create rollback: runtime.deleteWorkspace threw", {
392+
taskId,
393+
error: error instanceof Error ? error.message : String(error),
394+
});
395+
}
396+
397+
try {
398+
const sessionDir = this.config.getSessionDir(taskId);
399+
await fsPromises.rm(sessionDir, { recursive: true, force: true });
400+
} catch (error: unknown) {
401+
log.error("Task.create rollback: failed to remove session directory", {
402+
taskId,
403+
error: error instanceof Error ? error.message : String(error),
404+
});
405+
}
406+
}
407+
362408
waitForAgentReport(
363409
taskId: string,
364410
options?: { timeoutMs?: number; abortSignal?: AbortSignal }

0 commit comments

Comments
 (0)