fix: release manually approved plans (#3323)
## Summary Approving or rejecting a plan now releases the task instead of leaving it permanently blocked as `awaiting-approval`. The workflow routes use the task store's durable clear semantics, and approval records the current plan fingerprint while safely clearing a stale fingerprint when the plan cannot be read. Real PostgreSQL route coverage proves both actions clear the scheduler hold and persist the expected fingerprint state. Fixes #3322. ## Validation - `pnpm --filter @fusion/dashboard exec vitest run src/__tests__/plan-approval-status.pg.test.ts --silent=passed-only --reporter=dot` - `pnpm --filter @fusion/dashboard exec vitest run src/__tests__/routes-github.test.ts --silent=passed-only --reporter=dot -t 'POST /tasks/:id/(approve-plan|reject-plan)'` - `pnpm --filter @fusion/core exec vitest run src/__tests__/task-update-awaiting-approval-reason.test.ts --silent=passed-only --reporter=dot` - Core and dashboard typechecks - Scoped ESLint and strict changeset validation --- [](https://github.com/EveryInc/compound-engineering-plugin) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Manually approved plans now resume correctly in their workflow. * Rejected plans are fully cleared, preventing stale approval information from affecting future decisions. * Approval records accurately reflect the latest plan, including when plan details are unavailable. * Failed cleanup keeps rejected plans from being released prematurely. * **Tests** * Added coverage for approval and rejection persistence, workflow state, and cleanup failures. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/clear-plan-approval-status.md
Normal file
7
.changeset/clear-plan-approval-status.md
Normal file
@@ -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.
|
||||||
@@ -252,6 +252,13 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
|
|||||||
} else if (updates.status !== undefined) {
|
} else if (updates.status !== undefined) {
|
||||||
task.status = updates.status;
|
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:
|
FNXC:PlanApproval 2026-08-01-04:39:
|
||||||
`awaitingApprovalReason` was persisted (persistence.ts) and serialized (serialization.ts)
|
`awaitingApprovalReason` was persisted (persistence.ts) and serialized (serialization.ts)
|
||||||
@@ -1055,4 +1062,3 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts
Normal file
123
packages/dashboard/src/__tests__/plan-approval-status.pg.test.ts
Normal file
@@ -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",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2656,7 +2656,10 @@ describe("POST /tasks/:id/approve-plan", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
|
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Plan approved by user");
|
||||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
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.column).toBe("todo");
|
||||||
expect(res.body.status).toBeUndefined();
|
expect(res.body.status).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -2783,7 +2786,7 @@ describe("POST /tasks/:id/approve-plan", () => {
|
|||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(localStore.updateTask).toHaveBeenCalledWith(
|
expect(localStore.updateTask).toHaveBeenCalledWith(
|
||||||
"FN-001",
|
"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 {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
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");
|
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
|
// FN-7569: reject-plan clears any previously-recorded approval fingerprint so a
|
||||||
// regenerated plan is always treated as new and requires fresh manual approval.
|
// 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
|
/* 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. */
|
#2515 removed `triage`. Reject clears status for regeneration; it does not move the card. */
|
||||||
expect(res.body.column).toBe("todo");
|
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");
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
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)
|
// 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");
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
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");
|
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/reject-plan");
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4064,13 +4064,20 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
|
|
||||||
// Move to todo and clear status
|
// Move to todo and clear status
|
||||||
const reboundColumn = await resolveReboundColumnForTask(scopedStore, task.id);
|
const reboundColumn = await resolveReboundColumnForTask(scopedStore, task.id);
|
||||||
const updated = await scopedStore.moveTask(task.id, reboundColumn);
|
await scopedStore.moveTask(task.id, reboundColumn);
|
||||||
await scopedStore.updateTask(task.id, {
|
/*
|
||||||
status: undefined,
|
* FNXC:PlanApproval 2026-08-03-18:53:
|
||||||
...(approvedPlanFingerprint ? { approvedPlanFingerprint } : {}),
|
* 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) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -4103,6 +4110,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
|||||||
// Log the rejection
|
// Log the rejection
|
||||||
await scopedStore.logEntry(task.id, "Plan rejected by user", "Specification will be regenerated");
|
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
|
// Clear status to return to normal triage state
|
||||||
/*
|
/*
|
||||||
* FNXC:PlanApproval 2026-07-04-22:41:
|
* 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
|
* 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).
|
* requires fresh manual approval (it must never inherit the rejected plan's fingerprint).
|
||||||
*/
|
*/
|
||||||
await scopedStore.updateTask(task.id, { status: undefined, approvedPlanFingerprint: null });
|
await scopedStore.updateTask(task.id, { status: null, 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 });
|
|
||||||
|
|
||||||
const updated = await scopedStore.getTask(task.id);
|
const updated = await scopedStore.getTask(task.id);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
|
|||||||
Reference in New Issue
Block a user