FN-7526: guard plan auto-approval routing

Adds regression coverage ensuring project auto-approve-all sends eligible plans to todo without weakening independent gates.

- Cover Plan Review retry routing when workflow-stored requirePlanApproval is overridden by project auto-approve-all.
- Verify release authorization and Workflow Plan Review still block independently under auto-approve-all.
- Add refinement and self-healing routing coverage plus a patch changeset for the published CLI package.

Files changed:
 .changeset/fn-7526-plan-auto-approve.md            |   7 ++
 .../self-healing-starved-refinement.test.ts        |  61 +++++++++
 .../__tests__/triage-refinement-routing.test.ts    |  54 ++++++++
 packages/engine/src/__tests__/triage.test.ts       | 139 +++++++++++++++++++++
 packages/engine/src/triage.ts                      |   3 +
 5 files changed, 264 insertions(+)

Fusion-Task-Id: FN-7526

Fusion-Task-Lineage: 642d7856-d546-423b-bf10-68c28e205e21

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 12:25:18 -07:00
parent 9f24b507fc
commit 4707eb5be0
5 changed files with 264 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Auto-approve now reliably sends specified plans to the board without a manual approval stop.
category: fix
dev: FN-7526 — investigated the reported "plans still park at awaiting-approval when auto-approve is on" symptom; resolvePlanApprovalRequired, mergeEffectiveSettings/applyWorkflowSettingsOverlay, and every finalizeApprovedTask call site (specifyTask, recoverApprovedTask, retryUnavailablePlanReview, tryFinalizeExplicitDuplicateMarker) already honored project planApprovalMode: "auto-approve-all" over a stored workflow requirePlanApproval value — no production defect reproduced. Added end-to-end regression coverage across every enumerated surface (Plan Review reviewer-outage retry, refinement routing, self-healing starved-refinement recovery) using the real mergeEffectiveSettings pipeline instead of isolated bare-settings unit calls, plus explicit assertions that the independent release-authorization and Workflow Plan Review gates remain intact under auto-approve-all, so a future bare-settings call site is caught immediately instead of silently reintroducing the reported behavior.

View File

@@ -191,4 +191,65 @@ describe("SelfHealingManager.recoverStarvedRefinementTriageTasks", () => {
await rm(root, { recursive: true, force: true });
}
});
/*
* FNXC:PlanApproval 2026-07-04-12:20:
* FN-7526 — locks the auto-approve-all invariant for the starved-refinement
* finalize surface specifically, using the REAL mergeEffectiveSettings pipeline
* (not a bare `{ requirePlanApproval }` object) so a project auto-approve-all
* override still wins even when the stored workflow value would otherwise
* require manual plan approval. This is the surface `recoverApprovedTask`
* exercises when self-healing recovers a starved refinement stuck in
* `status: "planning"`.
*/
it("moves a starved refinement to todo when project auto-approve-all overrides stored workflow approval", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-fn7526-refine-"));
try {
const taskDir = join(root, ".fusion", "tasks", "FN-RG2");
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# FN-RG2\n\n## File Scope\n- packages/engine/src/self-healing.ts\n", "utf-8");
const updateTask = vi.fn().mockResolvedValue(undefined);
const moveTask = vi.fn().mockResolvedValue(undefined);
const store: any = {
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
planApprovalMode: "auto-approve-all",
requirePlanApproval: false,
}),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getWorkflowSettingValues: vi.fn().mockReturnValue({ requirePlanApproval: true }),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-auto-approval"),
updateTask,
moveTask,
logEntry: vi.fn().mockResolvedValue(undefined),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
on: () => {},
off: () => {},
removeListener: () => {},
};
const refinement = task({
id: "FN-RG2",
sourceType: "task_refine",
status: "planning",
log: [{ timestamp: "2026-05-15T10:00:00.000Z", action: "Spec review: APPROVE" }],
});
const processor = new TriageProcessor(store, root);
const recovered = await processor.recoverApprovedTask(refinement);
expect(recovered).toBe(true);
expect(moveTask).toHaveBeenCalledWith("FN-RG2", "todo");
expect(updateTask).not.toHaveBeenCalledWith("FN-RG2", expect.objectContaining({ status: "awaiting-approval" }));
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View File

@@ -155,6 +155,60 @@ describe("refinement routing from triage", () => {
expect(store.moveTask).toHaveBeenCalledWith(taskId, "todo");
});
/*
* FNXC:PlanApproval 2026-07-04-12:22:
* FN-7526 — locks the auto-approve-all invariant specifically for refinement
* (`sourceType: "task_refine"`) tasks routed through the real mergeEffectiveSettings
* pipeline (recoverApprovedTask), not just the isolated finalizeApprovedTask unit
* calls above which pass a bare `{ requirePlanApproval }` object. Proves the
* settings object handed to finalizeApprovedTask for a refinement still carries
* the project planApprovalMode even when the workflow has a stored
* requirePlanApproval: true value.
*/
it("moves a refinement to todo when project auto-approve-all overrides stored workflow approval", async () => {
const rootDir = await createRoot();
const taskId = "FN-R4";
const taskDir = join(rootDir, ".fusion", "tasks", taskId);
await mkdir(taskDir, { recursive: true });
await writeFile(join(taskDir, "PROMPT.md"), "# FN-R4\n\n## File Scope\n- packages/engine/src/triage.ts\n");
const store: any = withStoreEvents({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxTriageConcurrent: 2,
pollIntervalMs: 10_000,
groupOverlappingFiles: false,
autoMerge: true,
planApprovalMode: "auto-approve-all",
requirePlanApproval: false,
}),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getWorkflowSettingValues: vi.fn().mockReturnValue({ requirePlanApproval: true }),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-auto-approval"),
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
parseStepsFromPrompt: vi.fn().mockResolvedValue([]),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
});
const processor = new TriageProcessor(store, rootDir);
const task = createTriageTask({
id: taskId,
sourceType: "task_refine",
sourceParentTaskId: "FN-003",
status: "planning",
log: [{ timestamp: "2026-05-15T12:00:00.000Z", action: "Spec review: APPROVE" }],
});
const recovered = await processor.recoverApprovedTask(task);
expect(recovered).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith(taskId, "todo");
expect(store.updateTask).not.toHaveBeenCalledWith(taskId, expect.objectContaining({ status: "awaiting-approval" }));
});
it("retains baseline ordering for non-refinement triage tasks", async () => {
const rootDir = await createRoot();
const tasks: Task[] = [

View File

@@ -2049,6 +2049,63 @@ describe("TriageProcessor", () => {
}
});
/*
* FNXC:PlanApproval 2026-07-04-12:25:
* FN-7526 — locks the auto-approve-all invariant on the Plan Review reviewer-outage
* retry surface (retryUnavailablePlanReview, dispatched from specifyTask for
* status: "plan-review-unavailable"). Plan Review APPROVE clears the independent
* Plan Review gate; the manual plan-approval gate must then still honor project
* planApprovalMode: "auto-approve-all" over the workflow's stored
* requirePlanApproval: true and move the task straight to todo (never
* awaiting-approval).
*/
it("moves Plan Review retry to todo when project auto-approve-all overrides stored workflow approval", async () => {
const tempRoot = await createTriageFixtureRoot("fusion-triage-plan-review-retry-auto-approve-");
const taskId = "FN-PLAN-RETRY-AUTO-APPROVE";
const promptPath = join(tempRoot, ".fusion", "tasks", taskId, "PROMPT.md");
const prompt = `# Task: ${taskId} - Retry review auto-approve\n\n## Mission\n\nReviewer approves; project auto-approve-all must still win.\n`;
try {
await mkdir(join(tempRoot, ".fusion", "tasks", taskId), { recursive: true });
await writeFile(promptPath, prompt, "utf-8");
const retryTask = createTriageTask({
id: taskId,
title: "Retry review auto-approve",
status: "plan-review-unavailable",
nextRecoveryAt: "2026-01-01T00:00:00.000Z",
enabledWorkflowSteps: ["plan-review", "code-review"],
} as Partial<Task>);
const retryStore = createMockStore({
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getWorkflowSettingValues: vi.fn().mockReturnValue({ requirePlanApproval: true }),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-auto-approval"),
} as Partial<TaskStore>);
(retryStore.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(retryTask);
(retryStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
planApprovalMode: "auto-approve-all",
requirePlanApproval: false,
} as Settings);
const retryProcessor = new TriageProcessor(retryStore, tempRoot);
mockCreateFnAgent.mockClear();
mockReviewStep.mockResolvedValue({
verdict: "APPROVE",
review: "### Verdict: APPROVE\n\n### Summary\nReady.",
summary: "Ready.",
});
await retryProcessor.specifyTask(retryTask);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(retryStore.moveTask).toHaveBeenCalledWith(taskId, "todo");
expect(retryStore.updateTask).not.toHaveBeenCalledWith(taskId, expect.objectContaining({ status: "awaiting-approval" }));
} finally {
await cleanupTriageFixtureRoot(tempRoot);
}
});
it("includes workflow discovery and selection tools in the full triage toolset", async () => {
const task = createTriageTask({ id: "FN-WORKFLOW-TOOLS" });
const detailedTask = { ...mockTaskDetail, id: task.id, attachments: [], comments: [] };
@@ -2576,6 +2633,88 @@ describe("requirePlanApproval setting", () => {
expect(store.moveTask).not.toHaveBeenCalled();
});
/*
* FNXC:PlanApproval 2026-07-04-12:28:
* FN-7526 — auto-approve-all must NOT bypass the independent release-authorization
* gate. Both gates set status: "awaiting-approval", so this asserts the release
* gate's own activity/log evidence (recordActivity type
* "task:release-authorization-required", distinct log copy) fires instead of the
* ordinary manual-approval log line, proving the release gate — not the manual
* gate — is what parked the task.
*/
it("release-authorization gate still parks a release-class task even when auto-approve-all is on", async () => {
const task = createTriageTask({
id: "FN-RELEASE",
title: "Release @runfusion/fusion patch",
status: "planning",
sourceType: "agent_heartbeat",
} as Partial<Task>);
const recordActivity = vi.fn().mockResolvedValue(undefined);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
recordActivity,
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
"# Task: FN-RELEASE - Release @runfusion/fusion patch\n\n## Mission\n\nRun pnpm release --yes.\n",
{ requirePlanApproval: false, planApprovalMode: "auto-approve-all" } as Settings,
);
expect(store.updateTask).toHaveBeenCalledWith("FN-RELEASE", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.moveTask).not.toHaveBeenCalled();
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ type: "task:release-authorization-required" }));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-RELEASE",
"Release authorization required — leaving task in triage awaiting release authorization",
expect.any(String),
);
});
/*
* FNXC:PlanApproval 2026-07-04-12:30:
* FN-7526 — auto-approve-all must NOT bypass Workflow Plan Review. A REVISE
* verdict routes to status: "needs-replan" (never reaches the manual
* resolvePlanApprovalRequired gate at all), which is distinct from the manual
* gate's "awaiting-approval" outcome and proves the two gates remain independent.
*/
it("Plan Review still blocks execution on REVISE even when auto-approve-all is on", async () => {
const task = createTriageTask({
id: "FN-PLAN-REVIEW-AUTO-APPROVE",
title: "Plan review auto-approve",
status: "planning",
enabledWorkflowSteps: ["plan-review"],
} as Partial<Task>);
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(task),
} as Partial<TaskStore>);
const processor = new TriageProcessor(store, rootDir);
mockReviewStep.mockReset();
mockReviewStep.mockResolvedValue({
verdict: "REVISE",
review: "### Verdict: REVISE\n\nAdd acceptance criteria.",
summary: "Needs revision.",
});
await (processor as unknown as {
finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise<void>;
}).finalizeApprovedTask(
task,
"# Task: FN-PLAN-REVIEW-AUTO-APPROVE - Plan review auto-approve\n\n## Mission\n\nDo it.\n",
{ requirePlanApproval: true, planApprovalMode: "auto-approve-all" } as Settings,
);
expect(store.updateTask).toHaveBeenCalledWith("FN-PLAN-REVIEW-AUTO-APPROVE", expect.objectContaining({
status: "needs-replan",
}));
expect(store.moveTask).not.toHaveBeenCalled();
// The manual gate's own awaiting-approval update must never fire for this path.
expect(store.updateTask).not.toHaveBeenCalledWith("FN-PLAN-REVIEW-AUTO-APPROVE", { status: "awaiting-approval" });
});
it("clears stale workflow step instances when a fresh accepted plan replaces existing steps", async () => {
const task = createTriageTask({
id: "FN-7224",

View File

@@ -2532,6 +2532,9 @@ export class TriageProcessor {
FNXC:PlanApproval 2026-07-01-08:12:
This is the ordinary manual plan-approval gate only, after release authorization and Workflow Plan Review have already made their independent decisions. Always call resolvePlanApprovalRequired with the merged settings object so project auto-approve-all can override workflow requirePlanApproval without weakening non-plan safety gates.
FNXC:PlanApproval 2026-07-04-12:15:
FN-7526 re-verified this invariant end to end: every finalizeApprovedTask caller (specifyTask, recoverApprovedTask, retryUnavailablePlanReview, tryFinalizeExplicitDuplicateMarker) already derives `settings` from mergeEffectiveSettings so planApprovalMode (never a MOVED_SETTINGS_KEYS/workflow-owned key) survives any stored workflow requirePlanApproval overlay untouched. No production defect was found; regression tests were added across every surface to lock the invariant so a future bare-settings call site (e.g. `{ requirePlanApproval }` without planApprovalMode) is caught immediately instead of silently reintroducing the reported parking behavior.
*/
if (resolvePlanApprovalRequired(settings)) {
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };