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:
5
.changeset/fn-5907-planning-create-fetch.md
Normal file
5
.changeset/fn-5907-planning-create-fetch.md
Normal 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.
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
await runPlanningCreateSideEffect(
|
||||||
|
"Planning create-tasks dependency update failed",
|
||||||
|
async () => {
|
||||||
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies });
|
const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies });
|
||||||
createdTasks[index] = updated;
|
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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user