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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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(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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user