diff --git a/.changeset/clear-plan-approval-status.md b/.changeset/clear-plan-approval-status.md new file mode 100644 index 0000000000..92aa0c132b --- /dev/null +++ b/.changeset/clear-plan-approval-status.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Release manually approved or rejected plans so their tasks can continue. +category: fix +dev: Uses TaskStore null-clear semantics and persists approved plan fingerprints through field merges. diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index df52ef2d7c..b53359c7bb 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -252,6 +252,13 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat } else if (updates.status !== undefined) { task.status = updates.status; } + // FNXC:PlanApproval 2026-08-03-19:03: `null` clears the fingerprint; + // `undefined` preserves it by omitting the field from this merge. + if (updates.approvedPlanFingerprint === null) { + task.approvedPlanFingerprint = undefined; + } else if (updates.approvedPlanFingerprint !== undefined) { + task.approvedPlanFingerprint = updates.approvedPlanFingerprint; + } /* FNXC:PlanApproval 2026-08-01-04:39: `awaitingApprovalReason` was persisted (persistence.ts) and serialized (serialization.ts) @@ -1055,4 +1062,3 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat return task; } } - diff --git a/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts b/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts new file mode 100644 index 0000000000..4b07afdc5a --- /dev/null +++ b/packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts @@ -0,0 +1,123 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import express from "express"; +import { afterEach, beforeEach, expect, it } from "vitest"; +import { computePlanApprovalFingerprint, isTaskBlockedOnApproval, type TaskStore } from "@fusion/core"; +import { + createTaskStoreForTest, + pgDescribe, + type PgTestHarness, +} from "../../../core/src/__test-utils__/pg-test-harness.js"; +import { createApiRoutes } from "../routes.js"; +import { request } from "../test-request.js"; + +pgDescribe("plan approval status persistence", () => { + let harness: PgTestHarness; + let store: TaskStore; + + beforeEach(async () => { + harness = await createTaskStoreForTest({ prefix: "fusion_plan_approval_status" }); + store = harness.store; + }); + + afterEach(async () => { + await harness.teardown(); + }); + + function createApp() { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return app; + } + + it("clears the approval hold and persists the approved plan fingerprint", async () => { + const task = await store.createTask({ description: "Approve this plan" }); + await store.updateTask(task.id, { + status: "awaiting-approval", + approvedPlanFingerprint: "stale-fingerprint", + }); + expect((await store.getTask(task.id)).approvedPlanFingerprint).toBe("stale-fingerprint"); + + const prompt = "# Approved plan\n\nImplement the requested behavior.\n"; + const taskDir = join(harness.rootDir, ".fusion", "tasks", task.id); + await mkdir(taskDir, { recursive: true }); + await writeFile(join(taskDir, "PROMPT.md"), prompt, "utf8"); + + const response = await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`); + + expect(response.status).toBe(200); + const persisted = await store.getTask(task.id); + expect(persisted.status).toBeUndefined(); + expect(isTaskBlockedOnApproval(persisted)).toBe(false); + expect(persisted.approvedPlanFingerprint).toBe(computePlanApprovalFingerprint(prompt)); + expect(response.body.approvedPlanFingerprint).toBe(persisted.approvedPlanFingerprint); + }); + + it("clears a prior fingerprint when the approved plan cannot be read", async () => { + const task = await store.createTask({ description: "Approve without a readable plan" }); + await store.updateTask(task.id, { + status: "awaiting-approval", + approvedPlanFingerprint: "stale-fingerprint", + }); + await rm(join(harness.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"), { force: true }); + expect((await store.getTask(task.id)).approvedPlanFingerprint).toBe("stale-fingerprint"); + + const response = await request(createApp(), "POST", `/api/tasks/${task.id}/approve-plan`); + + expect(response.status).toBe(200); + const persisted = await store.getTask(task.id); + expect(persisted.status).toBeUndefined(); + expect(isTaskBlockedOnApproval(persisted)).toBe(false); + expect(persisted.approvedPlanFingerprint).toBeUndefined(); + expect(response.body.approvedPlanFingerprint).toBeUndefined(); + }); + + it("clears the approval hold and stale fingerprint when rejecting a plan", async () => { + const task = await store.createTask({ description: "Reject this plan" }); + await store.updateTask(task.id, { + status: "awaiting-approval", + approvedPlanFingerprint: "stale-fingerprint", + }); + expect((await store.getTask(task.id)).approvedPlanFingerprint).toBe("stale-fingerprint"); + + const response = await request(createApp(), "POST", `/api/tasks/${task.id}/reject-plan`); + + expect(response.status).toBe(200); + const persisted = await store.getTask(task.id); + expect(persisted.status).toBeUndefined(); + expect(isTaskBlockedOnApproval(persisted)).toBe(false); + expect(persisted.approvedPlanFingerprint).toBeUndefined(); + }); + + it("keeps the approval hold when the rejected plan cannot be removed", async () => { + /* + * FNXC:PlanApproval 2026-08-03-19:12 UTC: + * A rejected plan stays blocked and retains its approved fingerprint when + * PROMPT.md removal fails; only a successful removal may release the hold. + */ + const task = await store.createTask({ description: "Reject a plan that cannot be removed" }); + await store.updateTask(task.id, { + status: "awaiting-approval", + approvedPlanFingerprint: "rejected-fingerprint", + }); + + const promptPath = join(harness.rootDir, ".fusion", "tasks", task.id, "PROMPT.md"); + await rm(promptPath, { force: true }); + await mkdir(promptPath, { recursive: true }); + await writeFile(join(promptPath, "nested-plan.md"), "# Rejected plan\n", "utf8"); + + const response = await request(createApp(), "POST", `/api/tasks/${task.id}/reject-plan`); + + expect(response.status).toBe(500); + expect(response.body.error).toContain("PROMPT.md"); + const persisted = await store.getTask(task.id); + expect(persisted.status).toBe("awaiting-approval"); + expect(isTaskBlockedOnApproval(persisted)).toBe(true); + expect(persisted.approvedPlanFingerprint).toBe("rejected-fingerprint"); + expect(persisted.log).toContainEqual(expect.objectContaining({ + action: "Plan rejected by user", + outcome: "Specification will be regenerated", + })); + }); +}); diff --git a/packages/dashboard/src/__tests__/routes-github.test.ts b/packages/dashboard/src/__tests__/routes-github.test.ts index fbe380df53..0b3e8cefac 100644 --- a/packages/dashboard/src/__tests__/routes-github.test.ts +++ b/packages/dashboard/src/__tests__/routes-github.test.ts @@ -2656,7 +2656,10 @@ describe("POST /tasks/:id/approve-plan", () => { expect(res.status).toBe(200); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo"); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { + status: null, + approvedPlanFingerprint: null, + }); expect(res.body.column).toBe("todo"); expect(res.body.status).toBeUndefined(); }); @@ -2783,7 +2786,7 @@ describe("POST /tasks/:id/approve-plan", () => { expect(res.status).toBe(200); expect(localStore.updateTask).toHaveBeenCalledWith( "FN-001", - expect.objectContaining({ status: undefined, approvedPlanFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/) }), + expect.objectContaining({ status: null, approvedPlanFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/) }), ); } finally { rmSync(root, { recursive: true, force: true }); @@ -2823,7 +2826,7 @@ describe("POST /tasks/:id/reject-plan", () => { expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan rejected by user", "Specification will be regenerated"); // FN-7569: reject-plan clears any previously-recorded approval fingerprint so a // regenerated plan is always treated as new and requires fresh manual approval. - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined, approvedPlanFingerprint: null }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, approvedPlanFingerprint: null }); /* The rejected card stays where it was — the workflow's intake column, `todo` on the default lineage since #2515 removed `triage`. Reject clears status for regeneration; it does not move the card. */ expect(res.body.column).toBe("todo"); @@ -2892,7 +2895,7 @@ describe("POST /tasks/:id/reject-plan", () => { const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan"); expect(res.status).toBe(200); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined, approvedPlanFingerprint: null }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, approvedPlanFingerprint: null }); }); // Passthrough: an ordinary manual-approval hold (no awaitingApprovalReason) @@ -2912,7 +2915,7 @@ describe("POST /tasks/:id/reject-plan", () => { const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan"); expect(res.status).toBe(200); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined, approvedPlanFingerprint: null }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, approvedPlanFingerprint: null }); }); /* @@ -2935,7 +2938,7 @@ describe("POST /tasks/:id/reject-plan", () => { const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan"); expect(res.status).toBe(200); - expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined, approvedPlanFingerprint: null }); + expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: null, approvedPlanFingerprint: null }); }); }); diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 63aaef34d9..b10eaccdb6 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -4064,13 +4064,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // Move to todo and clear status const reboundColumn = await resolveReboundColumnForTask(scopedStore, task.id); - const updated = await scopedStore.moveTask(task.id, reboundColumn); - await scopedStore.updateTask(task.id, { - status: undefined, - ...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}), + await scopedStore.moveTask(task.id, reboundColumn); + /* + * FNXC:PlanApproval 2026-08-03-18:53: + * Approval must clear the durable awaiting-approval hold with TaskStore's explicit + * null sentinel; undefined omits a field from the patch. Persist the current plan's + * fingerprint, or explicitly clear a prior fingerprint when PROMPT.md was unreadable, + * so a stale plan can never bypass a later manual approval gate. + */ + const updated = await scopedStore.updateTask(task.id, { + status: null, + approvedPlanFingerprint: approvedPlanFingerprint ?? null, }); - res.json({ ...updated, status: undefined, ...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}) }); + res.json(updated); } catch (err: unknown) { if (err instanceof ApiError) { throw err; @@ -4103,6 +4110,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork // Log the rejection await scopedStore.logEntry(task.id, "Plan rejected by user", "Specification will be regenerated"); + /* + * FNXC:PlanApproval 2026-08-03-19:03: + * Remove PROMPT.md before releasing the approval hold. If removal fails, the rejected + * plan must remain blocked rather than becoming schedulable with rejected content. + */ + const { rm } = await import("node:fs/promises"); + const { join } = await import("node:path"); + const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"); + await rm(promptPath, { force: true }); + // Clear status to return to normal triage state /* * FNXC:PlanApproval 2026-07-04-22:41: @@ -4110,13 +4127,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork * clear and PROMPT.md removal, so the regenerated plan is always treated as new and * requires fresh manual approval (it must never inherit the rejected plan's fingerprint). */ - await scopedStore.updateTask(task.id, { status: undefined, approvedPlanFingerprint: null }); - - // Remove PROMPT.md to force regeneration - const { rm } = await import("node:fs/promises"); - const { join } = await import("node:path"); - const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"); - await rm(promptPath, { force: true }); + await scopedStore.updateTask(task.id, { status: null, approvedPlanFingerprint: null }); const updated = await scopedStore.getTask(task.id); res.json(updated);