FN-7381: honor project auto plan approval

Honor project-level auto plan approval across fresh and recovered triage finalization.

- Merge effective workflow settings during approved-task recovery before resolving the manual plan approval gate.
- Keep release authorization and Workflow Plan Review independent from manual plan approval bypasses.
- Add regression coverage for stored workflow approval settings overridden by project auto approval.
- Document the planApprovalMode precedence and release the fix as a patch.

Files changed:
 .changeset/fn-7381-auto-plan-approval.md           |  7 ++++
 docs/settings-reference.md                         |  2 +-
 docs/workflow-steps.md                             |  2 ++
 .../src/__tests__/effective-settings-merge.test.ts | 12 +++++++
 .../triage-plan-review-unavailable-retry.test.ts   | 27 +++++++++++++-
 packages/engine/src/__tests__/triage.test.ts       | 42 ++++++++++++++++++++++
 packages/engine/src/triage.ts                      | 13 ++++---
 7 files changed, 99 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7381
Fusion-Task-Lineage: c00fd0d9-ec0a-4dc5-8d6f-b49640157365
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-01 10:49:58 -07:00
parent e341c0673f
commit d94c3591d3
7 changed files with 99 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Honor project auto plan approval across task finalization paths.
category: fix
dev: Ensures planApprovalMode=auto-approve-all wins over workflow requirePlanApproval for ordinary plan approval.

View File

@@ -397,7 +397,7 @@ Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteF
| `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. |
| `allowAbsoluteFileBrowserPaths` | `boolean` | `false` | Project-scoped Settings → General toggle for the workspace file browser. When enabled, slash-prefixed paths such as `/tmp` can be listed/read/written/downloaded through workspace file-browser routes while keeping existing file-size, binary, type, null-byte, traversal, and permission checks. Windows drive-letter paths remain blocked, and task-local file routes, memory APIs, worktree-copy validation, plugin bundle paths, and other validators are unchanged. |
| `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. |
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every specified task to todo without manual approval; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. |
| `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the manual planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every successfully specified task to `todo` without manual plan approval even when the selected workflow or stored workflow setting has `requirePlanApproval: true`; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. This does not disable Workflow Plan Review, release authorization, or other non-plan safety gates. |
| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. |
| `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. |
| `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). |

View File

@@ -210,6 +210,8 @@ The default built-in catalog entry `builtin:coding` is backed by a Stepwise-deri
If the Plan Review reviewer is unavailable before producing a verdict, the task stays in triage as `status: "plan-review-unavailable"` with a short backoff. That retry state is not a replan: Fusion rereads the existing non-empty `PROMPT.md`, preserves it unchanged, and reruns only Plan Review/finalization while holding a global agent concurrency slot for the reviewer lane. A reviewer revision verdict moves the task to `needs-replan`; missing/empty/invalid prompt content fails clearly instead of restarting the planner.
Workflow Plan Review is separate from manual plan approval. Project `planApprovalMode: "auto-approve-all"` bypasses only the final manual `awaiting-approval` plan gate after the plan is specified and any enabled Plan Review passes; it does not disable Plan Review, release authorization, or other explicit safety gates.
`builtin:legacy-coding` is backed by the original monolithic `BUILTIN_CODING_WORKFLOW_IR`: `planning` → `execute` → optional quality gates → `review` → merge region.
`builtin:stepwise-coding` displays as Coding (per-step review). It is backed by `BUILTIN_STEPWISE_CODING_WORKFLOW_IR`; it keeps the same lifecycle columns/traits while adding the default-on optional Plan Review before `parse-steps`, modeling per-step parse/execute/review/rework as authored graph structure, and retaining the post-foreach optional Code Review gate before its final review/merge region.

View File

@@ -10,6 +10,7 @@ function baseSettings(): Settings {
return {
workflowStepTimeoutMs: 900_000,
requirePrApproval: false,
requirePlanApproval: false,
runStepsInNewSessions: false,
// A real project value for an absent-default lane — must NOT be clobbered.
executionProvider: "project-anthropic",
@@ -83,6 +84,17 @@ describe("mergeEffectiveSettings (engine entry merge, U3/KTD-3)", () => {
expect(merged.executionModelId).toBe("claude-project");
});
it("preserves project planApprovalMode while applying stored workflow requirePlanApproval", async () => {
const store = makeStore({
workflowId: "builtin:coding",
values: { requirePlanApproval: true },
});
const base = { ...baseSettings(), planApprovalMode: "auto-approve-all", requirePlanApproval: false } as unknown as Settings;
const merged = await mergeEffectiveSettings(store as any, { id: "t1" }, base);
expect(merged.planApprovalMode).toBe("auto-approve-all");
expect(merged.requirePlanApproval).toBe(true);
});
it("returns a NEW object; the base is not mutated", async () => {
const store = makeStore({ workflowId: "builtin:coding", values: { workflowStepTimeoutMs: 1 } });
const base = baseSettings();

View File

@@ -54,7 +54,7 @@ function createRetryTask(overrides: Partial<Task> = {}): Task {
} as Task;
}
function createStore(task: Task): TaskStore {
function createStore(task: Task, settingsOverrides: Partial<Settings> = {}): TaskStore {
return {
getTask: vi.fn().mockResolvedValue(task),
listTasks: vi.fn().mockResolvedValue([task]),
@@ -65,6 +65,7 @@ function createStore(task: Task): TaskStore {
groupOverlappingFiles: false,
autoMerge: true,
requirePlanApproval: false,
...settingsOverrides,
} as Settings),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
@@ -79,6 +80,10 @@ function createStore(task: Task): TaskStore {
mergeTask: vi.fn(),
updateSettings: vi.fn(),
addSteeringComment: vi.fn(),
getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }),
getWorkflowDefinition: vi.fn().mockResolvedValue(undefined),
getWorkflowSettingValues: vi.fn().mockReturnValue({}),
getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-plan-review-retry"),
on: vi.fn(),
emit: vi.fn(),
} as unknown as TaskStore;
@@ -138,6 +143,26 @@ describe("Plan Review unavailable retry", () => {
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
});
it("moves an approved retry to todo when project auto approval overrides workflow approval", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-PLAN-RETRY-AUTO-APPROVE" });
const prompt = `# Task: ${task.id} - Existing draft\n\n## Mission\n\nKeep this exact text.\n`;
const promptPath = await writePrompt(rootDir, task.id, prompt);
const store = createStore(task, { planApprovalMode: "auto-approve-all", requirePlanApproval: false });
(store.getWorkflowSettingValues as ReturnType<typeof vi.fn>).mockReturnValue({ requirePlanApproval: true });
mockReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Approved.", summary: "Ready." });
await retryTask(rootDir, task, store);
expect(mockCreateFnAgent).not.toHaveBeenCalled();
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "todo");
expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({ status: "awaiting-approval" }));
const logActions = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.map(([, action]) => action);
expect(logActions).not.toContain("Specification approved by AI — awaiting manual approval");
});
it.each([
{
name: "unavailable verdict",

View File

@@ -2843,6 +2843,48 @@ Forbidden paths / non-goals:
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
it("moves recovered task to todo when project auto approval overrides stored workflow approval", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
planApprovalMode: "auto-approve-all",
requirePlanApproval: false,
} as Settings),
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>);
const processor = new TriageProcessor(store, rootDir);
const recovered = await processor.recoverApprovedTask({
id: "FN-001",
description: "Recovered triage task",
column: "triage",
status: "planning",
dependencies: [],
steps: [],
currentStep: 0,
log: [
{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" },
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:02:00.000Z",
});
expect(recovered).toBe(true);
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ status: "awaiting-approval" }));
expect(store.logEntry).toHaveBeenCalledWith(
"FN-001",
"Auto-recovered specified task stuck in planning — moved to todo",
);
});
it("moves approved planning task to awaiting-approval when manual approval is required", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({

View File

@@ -548,8 +548,10 @@ export class TriageProcessor {
return false;
}
// Merge per-task effective workflow settings (U3, KTD-3) so requirePlanApproval
// resolves from the workflow. Behavior-inert when nothing is customized.
/*
FNXC:PlanApproval 2026-07-01-08:12:
Recovery finalizes an already-written PROMPT.md and must use the same merged project/workflow settings as fresh triage. The project planApprovalMode value stays project-scoped while workflow requirePlanApproval may overlay, so auto-approve-all still wins for ordinary plan approval.
*/
const settings = await mergeEffectiveSettings(this.store, task, await this.store.getSettings());
const approvalRequired = resolvePlanApprovalRequired(settings);
const promptPath = join(this.rootDir, ".fusion", "tasks", task.id, "PROMPT.md");
@@ -2456,7 +2458,7 @@ export class TriageProcessor {
await this.store.updateTask(task.id, approvalUpdates);
await this.store.logEntry(
task.id,
"Release authorization required — leaving task in triage awaiting manual approval",
"Release authorization required — leaving task in triage awaiting release authorization",
details,
);
try {
@@ -2475,7 +2477,7 @@ export class TriageProcessor {
const message = activityError instanceof Error ? activityError.message : String(activityError);
planLog.warn(`${task.id}: failed to record release-authorization-required activity (${message})`);
}
planLog.log(`${task.id} release authorization required — leaving in triage awaiting manual approval (${signals})`);
planLog.log(`${task.id} release authorization required — leaving in triage awaiting release authorization (${signals})`);
return;
}
} catch (error: unknown) {
@@ -2504,6 +2506,9 @@ export class TriageProcessor {
/*
FNXC:PlanApproval 2026-06-26-00:00:
Project planApprovalMode has precedence over the workflow-resolved requirePlanApproval value so operators can force auto-approval or manual approval for every task in this project.
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.
*/
if (resolvePlanApprovalRequired(settings)) {
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };