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:
7
.changeset/fn-7526-plan-auto-approve.md
Normal file
7
.changeset/fn-7526-plan-auto-approve.md
Normal 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.
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user