FN-5907: harden planning task creation side effects

Keep Planning Mode task creation responses successful when follow-up work fails.

- catch and log planning create-task/create-tasks side-effect failures so the API can still return 201 after task creation succeeds
- safely handle async task lifecycle listener failures in the core store to avoid leaking unhandled rejections during follow-up updates
- add regression coverage for single-task and multi-task Planning Mode creation across live/persisted sessions, branch selection surfaces, and GitHub tracking failures

Files changed:
 .changeset/fn-5907-planning-create-fetch.md        |   5 +
 .../core/src/__tests__/task-creation-hook.test.ts  |  25 ++
 packages/core/src/store.ts                         |  38 ++-
 .../src/__tests__/routes-planning-tracking.test.ts | 108 ++++++++-
 .../src/__tests__/routes-planning.test.ts          | 267 +++++++++++++++++++++
 .../src/routes/register-planning-subtask-routes.ts |  69 +++++-
 6 files changed, 496 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-5907

Fusion-Task-Lineage: d9f6574b-e19b-4459-a612-7a3d2510836f
This commit is contained in:
gsxdsm
2026-06-03 07:50:59 -07:00
parent 419f688afb
commit ac92174cba
6 changed files with 496 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix a Planning Mode reliability bug where creating a single task could fail with a browser-level `Failed to fetch` error when post-create side effects threw or rejected before the dashboard finished responding.

View File

@@ -108,6 +108,31 @@ describe("task creation hook", () => {
expect(created2.id).toMatch(/^FN-/); expect(created2.id).toMatch(/^FN-/);
}); });
it("does not leak async task:updated listener rejections during create follow-up updates", async () => {
const store = harness.store();
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
process.on("unhandledRejection", onUnhandledRejection);
store.on("task:updated", async (task) => {
if (task.id.startsWith("FN-")) {
throw new Error(`listener boom for ${task.id}`);
}
});
try {
const task = await store.createTask({ description: "planning create listener safety" });
await store.updateTask(task.id, { size: "M" });
await store.logEntry(task.id, "Created via Planning Mode", "Initial plan: test");
await new Promise((resolve) => setImmediate(resolve));
expect(unhandledRejections).toHaveLength(0);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("can clear hook with undefined", async () => { it("can clear hook with undefined", async () => {
const store = harness.store(); const store = harness.store();
const hook = vi.fn(); const hook = vi.fn();

View File

@@ -1224,6 +1224,34 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir); this.globalSettingsStore = new GlobalSettingsStore(resolvedGlobalSettingsDir);
} }
private emitTaskLifecycleEventSafely(
event: "task:created" | "task:updated",
args: TaskStoreEvents["task:created"] | TaskStoreEvents["task:updated"],
): boolean {
const listeners = super.listeners(event) as Array<(...listenerArgs: typeof args) => unknown>;
if (listeners.length === 0) {
return false;
}
const [task] = args;
const taskId = task && typeof task === "object" && "id" in task ? String(task.id) : "unknown";
for (const listener of listeners) {
try {
const result = listener(...args);
if (result && typeof (result as PromiseLike<unknown>).then === "function") {
void Promise.resolve(result).catch((error) => {
storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`);
});
}
} catch (error) {
storeLog.warn(`[${event}] listener failed for ${taskId}: ${getErrorMessage(error)}`);
}
}
return true;
}
/** /**
* Get the SQLite database, initializing it on first access. * Get the SQLite database, initializing it on first access.
* Also performs auto-migration from legacy file-based storage if needed. * Also performs auto-migration from legacy file-based storage if needed.
@@ -4001,7 +4029,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await this._maybeAutoArchiveSameAgentDuplicate(task, input); await this._maybeAutoArchiveSameAgentDuplicate(task, input);
this.emit("task:created", task); this.emitTaskLifecycleEventSafely("task:created", [task]);
if (options?.invokeTaskCreatedHook !== false) { if (options?.invokeTaskCreatedHook !== false) {
await this.invokeTaskCreatedHook(task); await this.invokeTaskCreatedHook(task);
} }
@@ -5850,7 +5878,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (movedToTriage) { if (movedToTriage) {
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" });
} }
this.emit("task:updated", task); this.emitTaskLifecycleEventSafely("task:updated", [task]);
return task; return task;
}); });
} }
@@ -6521,7 +6549,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (movedToTriage) { if (movedToTriage) {
this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" }); this.emit("task:moved", { task, from: "todo" as Column, to: "triage" as Column, source: "engine" });
} }
this.emit("task:updated", task); this.emitTaskLifecycleEventSafely("task:updated", [task]);
return task; return task;
}); });
} }
@@ -6772,12 +6800,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
if (this.isWatching) { if (this.isWatching) {
this.taskCache.set(id, { ...current }); this.taskCache.set(id, { ...current });
} }
this.emit("task:updated", current); this.emitTaskLifecycleEventSafely("task:updated", [current]);
return current; return current;
} }
const emittedTask = ({ id, log, updatedAt } as unknown) as Task; const emittedTask = ({ id, log, updatedAt } as unknown) as Task;
this.emit("task:updated", emittedTask); this.emitTaskLifecycleEventSafely("task:updated", [emittedTask]);
return emittedTask; return emittedTask;
}); });
} }

View File

@@ -200,7 +200,39 @@ describe("planning routes github tracking background dispatch", () => {
expect(response.status).toBe(201); expect(response.status).toBe(201);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(planningWarn).toHaveBeenCalled(); expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue"));
});
});
it("POST /planning/create-task still returns 201 when createIssue throws synchronously", async () => {
createIssueSpy.mockImplementation(() => {
throw new Error("sync github crash");
});
sessions.set("plan-2-sync", {
summary: {
title: "Planned task 2",
description: "Planned task description 2",
suggestedSize: "M",
priority: "normal",
suggestedDependencies: [],
keyDeliverables: [],
},
initialPlan: "initial",
history: [],
});
const response = await performRequest(
app,
"POST",
"/planning/create-task",
JSON.stringify({ sessionId: "plan-2-sync" }),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
await vi.waitFor(() => {
expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue"));
}); });
}); });
@@ -250,4 +282,78 @@ describe("planning routes github tracking background dispatch", () => {
expect(createIssueSpy).toHaveBeenCalledTimes(2); expect(createIssueSpy).toHaveBeenCalledTimes(2);
}); });
}); });
it("POST /planning/create-tasks still returns 201 when createIssue rejects asynchronously", async () => {
createIssueSpy.mockRejectedValue(new Error("github down"));
sessions.set("plan-3-reject", {
summary: {
title: "Plan",
description: "Plan",
suggestedSize: "M",
priority: "normal",
suggestedDependencies: [],
keyDeliverables: [],
},
initialPlan: "initial",
history: [],
});
const response = await performRequest(
app,
"POST",
"/planning/create-tasks",
JSON.stringify({
planningSessionId: "plan-3-reject",
subtasks: [
{ id: "tmp-1", title: "Subtask 1", description: "D1" },
{ id: "tmp-2", title: "Subtask 2", description: "D2" },
],
}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
await vi.waitFor(() => {
expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue"));
});
});
it("POST /planning/create-tasks still returns 201 when createIssue throws synchronously", async () => {
createIssueSpy.mockImplementation(() => {
throw new Error("sync github crash");
});
sessions.set("plan-3-sync", {
summary: {
title: "Plan",
description: "Plan",
suggestedSize: "M",
priority: "normal",
suggestedDependencies: [],
keyDeliverables: [],
},
initialPlan: "initial",
history: [],
});
const response = await performRequest(
app,
"POST",
"/planning/create-tasks",
JSON.stringify({
planningSessionId: "plan-3-sync",
subtasks: [
{ id: "tmp-1", title: "Subtask 1", description: "D1" },
{ id: "tmp-2", title: "Subtask 2", description: "D2" },
],
}),
{ "content-type": "application/json" },
);
expect(response.status).toBe(201);
await vi.waitFor(() => {
expect(planningWarn).toHaveBeenCalledWith(expect.stringContaining("[github-tracking] Failed to create issue"));
});
});
}); });

View File

@@ -421,6 +421,23 @@ describe("Planning Mode Routes", () => {
return app; return app;
} }
async function createCompletedPlanningSession(initialPlan = "Build a user auth system"): Promise<string> {
const startRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start",
JSON.stringify({ initialPlan }),
{ "Content-Type": "application/json" }
);
const sessionId = startRes.body.sessionId;
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }), { "Content-Type": "application/json" });
await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { confirm: true } }), { "Content-Type": "application/json" });
return sessionId;
}
async function connectPlanningStreamUntilComplete(sessionId: string): Promise<void> { async function connectPlanningStreamUntilComplete(sessionId: string): Promise<void> {
const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`); const streamPromise = REQUEST(buildApp(), "GET", `/api/planning/${sessionId}/stream`);
setTimeout(() => { setTimeout(() => {
@@ -1952,6 +1969,256 @@ describe("Planning Mode Routes", () => {
}); });
}); });
it.each([
{
sessionSource: "live",
useSummaryOverride: false,
branchSelection: { mode: "project-default" },
expectedBranch: undefined,
expectedBaseBranch: undefined,
},
{
sessionSource: "live",
useSummaryOverride: true,
branchSelection: { mode: "auto-new", baseBranch: "develop" },
expectedBranch: undefined,
expectedBaseBranch: "develop",
},
{
sessionSource: "persisted",
useSummaryOverride: false,
branchSelection: { mode: "existing", branchName: "feature/shared-auth", baseBranch: "develop" },
expectedBranch: "feature/shared-auth",
expectedBaseBranch: "develop",
},
{
sessionSource: "persisted",
useSummaryOverride: true,
branchSelection: { mode: "custom-new", branchName: "feature/planned-auth", baseBranch: "main" },
expectedBranch: "feature/planned-auth",
expectedBaseBranch: "main",
},
])("returns 201 for $sessionSource create-task sessions across branch selection surfaces (summary override: $useSummaryOverride)", async ({
sessionSource,
useSummaryOverride,
branchSelection,
expectedBranch,
expectedBaseBranch,
}) => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: `FN-${sessionSource}-${branchSelection.mode}`,
description: "Build auth flow",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
let app = buildApp();
let sessionId: string;
if (sessionSource === "live") {
sessionId = await createCompletedPlanningSession();
} else {
sessionId = `persisted-${branchSelection.mode}-${useSummaryOverride ? "override" : "default"}`;
const persistedSession = {
id: sessionId,
type: "planning",
status: "complete",
title: "Build persisted planning",
inputPayload: JSON.stringify({ initialPlan: "Build resumable planning sessions" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify({
title: "Persisted planning output",
description: "Persist planning results so users can create tasks later",
suggestedSize: "M",
priority: "normal",
suggestedDependencies: ["FN-100"],
keyDeliverables: ["Persist sessions"],
}),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archived: 0,
};
const mockAiSessionStore = {
get: vi.fn((id: string) => (id === sessionId ? persistedSession : null)),
listAll: vi.fn(() => []),
delete: vi.fn(),
};
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { aiSessionStore: mockAiSessionStore as any }));
}
const summary = useSummaryOverride
? {
title: "Edited auth task",
description: "Edited description from summary view",
suggestedSize: "S",
suggestedDependencies: ["FN-500"],
keyDeliverables: ["Login flow"],
}
: undefined;
const res = await REQUEST(
app,
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId, branchSelection, ...(summary ? { summary } : {}) }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: useSummaryOverride ? "Edited auth task" : expect.any(String),
branch: expectedBranch,
baseBranch: expectedBaseBranch,
}),
);
});
it.each([
{ label: "size update rejection", configure: () => (store.updateTask as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("size update failed")) },
{ label: "log entry rejection", configure: () => (store.logEntry as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("log entry failed")) },
])("still returns 201 when planning create-task post-create side effects fail (%s)", async ({ configure }) => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-250",
description: "Build a user auth system",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
configure();
const sessionId = await createCompletedPlanningSession();
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
});
it("still returns 201 when planning create-task session release throws", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-251",
description: "Build a user auth system",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const releaseSessionSpy = vi.spyOn(planningModule, "releaseSession").mockImplementation(() => {
throw new Error("release exploded");
});
try {
const sessionId = await createCompletedPlanningSession();
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-task",
JSON.stringify({ sessionId }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
} finally {
releaseSessionSpy.mockRestore();
}
});
it("still returns 201 when planning create-tasks post-create updates fail", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
id: "FN-260",
description: "First",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
})
.mockResolvedValueOnce({
id: "FN-261",
description: "Second",
column: "triage",
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.updateTask as ReturnType<typeof vi.fn>)
.mockRejectedValueOnce(new Error("size update failed"))
.mockResolvedValueOnce({
id: "FN-261",
description: "Second",
column: "triage",
dependencies: ["FN-260"],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
(store.logEntry as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const planningSessionId = await createCompletedPlanningSession();
const breakdownRes = await REQUEST(
buildApp(),
"POST",
"/api/planning/start-breakdown",
JSON.stringify({ sessionId: planningSessionId }),
{ "Content-Type": "application/json" }
);
const generatedSubtasks = breakdownRes.body.subtasks as Array<{
id: string;
title: string;
description: string;
suggestedSize: "S" | "M" | "L";
dependsOn: string[];
}>;
const res = await REQUEST(
buildApp(),
"POST",
"/api/planning/create-tasks",
JSON.stringify({
planningSessionId,
subtasks: [
{
id: generatedSubtasks[0]!.id,
title: "Auth backend",
description: "Implement backend",
suggestedSize: "L",
dependsOn: [],
},
{
id: generatedSubtasks[1]!.id,
title: "Auth frontend",
description: "Implement frontend",
dependsOn: [generatedSubtasks[0]!.id],
},
],
}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(201);
expect(res.body.tasks).toHaveLength(2);
});
it("creates task with explicit summary priority", async () => { it("creates task with explicit summary priority", async () => {
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({ (store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-100", id: "FN-100",

View File

@@ -983,6 +983,25 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}; };
}; };
const logPlanningCreateWarning = (message: string, error: unknown, metadata?: Record<string, unknown>): void => {
planningLogger.warn(message, {
...metadata,
error: error instanceof Error ? error.message : String(error),
});
};
const runPlanningCreateSideEffect = async (
message: string,
work: () => Promise<unknown> | unknown,
metadata?: Record<string, unknown>,
): Promise<void> => {
try {
await work();
} catch (error) {
logPlanningCreateWarning(message, error, metadata);
}
};
/** /**
* POST /api/planning/create-task * POST /api/planning/create-task
* Create a task from a completed planning session. * Create a task from a completed planning session.
@@ -1103,18 +1122,30 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
baseBranch: resolvedBaseBranch, baseBranch: resolvedBaseBranch,
}); });
// Update task with suggested size if provided // Update task with suggested size if provided.
if (summary.suggestedSize) { if (summary.suggestedSize) {
await scopedStore.updateTask(task.id, { size: summary.suggestedSize }); await runPlanningCreateSideEffect(
"Planning create-task size update failed",
() => scopedStore.updateTask(task.id, { size: summary.suggestedSize }),
{ taskId: task.id, sessionId },
);
} }
// Log the planning mode creation // Log the planning mode creation.
await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`); await runPlanningCreateSideEffect(
"Planning create-task log entry failed",
() => scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`),
{ taskId: task.id, sessionId },
);
// Release any live in-memory planning runtime for this session, but // Release any live in-memory planning runtime for this session, but
// keep the persisted completed row so planning history can still list // keep the persisted completed row so planning history can still list
// and restore the summary after single-task creation. // and restore the summary after single-task creation.
releaseSession(sessionId); await runPlanningCreateSideEffect(
"Planning create-task session release failed",
() => releaseSession(sessionId),
{ taskId: task.id, sessionId },
);
res.status(201).json(task); res.status(201).json(task);
} catch (err: unknown) { } catch (err: unknown) {
@@ -1314,7 +1345,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
createdTasks.push(task); createdTasks.push(task);
if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") { if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") {
await scopedStore.updateTask(task.id, { size: item.suggestedSize }); await runPlanningCreateSideEffect(
"Planning create-tasks size update failed",
() => scopedStore.updateTask(task.id, { size: item.suggestedSize }),
{ taskId: task.id, planningSessionId },
);
} }
} }
@@ -1326,14 +1361,28 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
: []; : [];
if (resolvedDependencies.length > 0) { if (resolvedDependencies.length > 0) {
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies }); await runPlanningCreateSideEffect(
createdTasks[index] = updated; "Planning create-tasks dependency update failed",
async () => {
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies });
createdTasks[index] = updated;
},
{ taskId: created.id, planningSessionId },
);
} }
await scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails); await runPlanningCreateSideEffect(
"Planning create-tasks log entry failed",
() => scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails),
{ taskId: created.id, planningSessionId },
);
} }
cleanupSession(planningSessionId); await runPlanningCreateSideEffect(
"Planning create-tasks session cleanup failed",
() => cleanupSession(planningSessionId),
{ planningSessionId },
);
res.status(201).json({ tasks: createdTasks }); res.status(201).json({ tasks: createdTasks });
} catch (err: unknown) { } catch (err: unknown) {