diff --git a/.changeset/fn-7526-plan-auto-approve.md b/.changeset/fn-7526-plan-auto-approve.md new file mode 100644 index 0000000000..7dce0c5a0b --- /dev/null +++ b/.changeset/fn-7526-plan-auto-approve.md @@ -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. diff --git a/packages/engine/src/__tests__/self-healing-starved-refinement.test.ts b/packages/engine/src/__tests__/self-healing-starved-refinement.test.ts index d245db530f..a2073d51d1 100644 --- a/packages/engine/src/__tests__/self-healing-starved-refinement.test.ts +++ b/packages/engine/src/__tests__/self-healing-starved-refinement.test.ts @@ -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 }); + } + }); }); diff --git a/packages/engine/src/__tests__/triage-refinement-routing.test.ts b/packages/engine/src/__tests__/triage-refinement-routing.test.ts index 1d13382ae3..7efa74944a 100644 --- a/packages/engine/src/__tests__/triage-refinement-routing.test.ts +++ b/packages/engine/src/__tests__/triage-refinement-routing.test.ts @@ -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[] = [ diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 6c0d6ca85e..837eb6bec4 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -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); + 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); + (retryStore.getTask as ReturnType).mockResolvedValue(retryTask); + (retryStore.getSettings as ReturnType).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); + const recordActivity = vi.fn().mockResolvedValue(undefined); + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue(task), + recordActivity, + } as Partial); + const processor = new TriageProcessor(store, rootDir); + + await (processor as unknown as { + finalizeApprovedTask(task: Task, writtenInput: string, settings: Settings): Promise; + }).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); + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue(task), + } as Partial); + 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; + }).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", diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 4e5895d52e..1451be830b 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -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 = { status: "awaiting-approval" };