feat(FN-3891): expose verification auto-fix retries in merge settings

- Add verificationFixRetries input to SettingsModal merge section with 0-3 clamping

- Default to 3 retries with proper empty-value fallback to undefined

- Cover edge cases with SettingsModal tests: default, valid values, clamping, clearing

- Add patch changeset for @runfusion/fusion

- Update settings-reference.md with revised default and description

Fusion-Task-Id: FN-3891
This commit is contained in:
Fusion
2026-05-11 08:09:08 -07:00
committed by gsxdsm
parent 81f143db18
commit d1f4d5f579
4 changed files with 85 additions and 19 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Expose verificationFixRetries (0-3) in Settings → Merge so users can tune in-merge auto-fix attempts without editing JSON.

View File

@@ -209,7 +209,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `mergerAutostashMaxAgeHours` | `number` | `24` | Maximum autostash age in hours before startup/periodic stale-stash sweep drops `fusion-merger-autostash:*` leftovers (minimum `1`). | | `mergerAutostashMaxAgeHours` | `number` | `24` | Maximum autostash age in hours before startup/periodic stale-stash sweep drops `fusion-merger-autostash:*` leftovers (minimum `1`). |
| `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. | | `strictScopeEnforcement` | `boolean` | `false` | Block merges on out-of-scope file changes. |
| `buildRetryCount` | `number` | `0` | Build retry attempts during merge. | | `buildRetryCount` | `number` | `0` | Build retry attempts during merge. |
| `verificationFixRetries` | `number` | `2` | Auto-fix retry attempts when verification fails during merge. | | `verificationFixRetries` | `number` | `3` | In-merge auto-fix retry attempts after deterministic test/build verification failures (0-3). |
| `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). | | `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). |
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. | | `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. |
| `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). | | `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). |

View File

@@ -3560,19 +3560,38 @@ export function SettingsModal({
</details> </details>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="verificationFixRetries">Verification fix retries</label> <label htmlFor="verificationFixRetries">Verification auto-fix retries</label>
<input <input
id="verificationFixRetries" id="verificationFixRetries"
className="input"
type="number" type="number"
min={0} min={0}
max={3} max={3}
value={form.verificationFixRetries ?? ""} step={1}
value={form.verificationFixRetries ?? 3}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const rawValue = e.target.value;
setForm((f) => ({ ...f, verificationFixRetries: val === "" ? undefined : Number(val) } as SettingsFormState)); if (rawValue === "") {
setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState));
return;
}
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue)) {
setForm((f) => ({ ...f, verificationFixRetries: undefined } as SettingsFormState));
return;
}
const clampedValue = Math.max(0, Math.min(3, parsedValue));
setForm((f) => ({ ...f, verificationFixRetries: clampedValue } as SettingsFormState));
}} }}
/> />
<small>Number of automatic fix attempts after failed merge verification (03)</small> <details className="settings-option-details">
<summary>More details</summary>
<small>
Controls in-merge fix attempts after deterministic test/build verification failures (0-3).
</small>
</details>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="mergeStrategy">Auto-completion mode</label> <label htmlFor="mergeStrategy">Auto-completion mode</label>

View File

@@ -1896,23 +1896,65 @@ describe("SettingsModal", () => {
expect(payload.pushRemote).toBe("upstream main"); expect(payload.pushRemote).toBe("upstream main");
}); });
it("renders and saves verification fix retries", async () => { describe("verificationFixRetries", () => {
renderModal({ initialSection: "merge" }); it("shows default value 3 when verificationFixRetries is not set", async () => {
await waitForSettingsModalReady(); mockFetchSettings.mockResolvedValueOnce({
...defaultSettings,
verificationFixRetries: undefined,
});
const retriesInput = screen.getByLabelText("Verification fix retries") as HTMLInputElement; renderModal({ initialSection: "merge" });
expect(retriesInput.value).toBe("2"); await waitForSettingsModalReady();
await userEvent.clear(retriesInput); const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
await userEvent.type(retriesInput, "1"); expect(retriesInput.value).toBe("3");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalled();
}); });
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>; it.each([0, 1, 2, 3])("persists valid value %i", async (value) => {
expect(payload.verificationFixRetries).toBe(1); renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
fireEvent.change(retriesInput, { target: { value: String(value) } });
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.verificationFixRetries).toBe(value);
});
it("clamps out-of-range values", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
fireEvent.change(retriesInput, { target: { value: "5" } });
expect(retriesInput.value).toBe("3");
fireEvent.change(retriesInput, { target: { value: "-1" } });
expect(retriesInput.value).toBe("0");
});
it("saving after clearing input persists undefined and falls back to visible default 3", async () => {
renderModal({ initialSection: "merge" });
await waitForSettingsModalReady();
const retriesInput = screen.getByLabelText("Verification auto-fix retries") as HTMLInputElement;
await userEvent.clear(retriesInput);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
expect(payload.verificationFixRetries).toBeUndefined();
expect(retriesInput.value).toBe("3");
});
}); });
it("renders and saves github issue tracking controls", async () => { it("renders and saves github issue tracking controls", async () => {