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

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](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:
gsxdsm
2026-08-03 12:24:28 -07:00
committed by GitHub
parent 56819e21e9
commit c8ff721362
5 changed files with 169 additions and 19 deletions

View 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.

View File

@@ -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;
}
}

View 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",
}));
});
});

View File

@@ -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 });
});
});

View File

@@ -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);