feat(FN-5435): reset fallback retry counter on success in reviewer

Adds a fallback retry counter to the reviewer that resets on successful step completion, preventing premature escalation when earlier steps exhaust retries while later ones succeed; also aligns dashboard trace buffer types and adds comprehensive coverage for the reset semantics.

Fusion-Task-Id: FN-5435
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 08:41:36 -07:00
committed by gsxdsm
parent f236dc3982
commit ef5dd7ba25
4 changed files with 104 additions and 10 deletions

View File

@@ -431,7 +431,68 @@ describe("reviewStep — fallback retry for terminal unavailable", () => {
"FN-4092",
expect.stringContaining("review retry with fallback model after UNAVAILABLE verdict"),
);
expect(task.reviewerFallbackRetryCount).toBe(1);
expect(task.reviewerFallbackRetryCount).toBe(0);
});
it("resets reviewerFallbackRetryCount to 0 after a successful review following prior fallbacks", async () => {
mockedCreateFnAgent
.mockResolvedValueOnce(createMockSession("No parseable verdict here."))
.mockResolvedValueOnce(createMockSession("### Verdict: APPROVE\n### Summary\nrecovered"));
const task = { id: "FN-4093", column: "in-progress", description: "d", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# prompt", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", reviewerFallbackRetryCount: 3 };
const store = {
getSettings: vi.fn().mockResolvedValue({ maxReviewerFallbackRetries: 8, maxTotalRetriesBeforeFail: 25 }),
getTask: vi.fn().mockImplementation(async () => task),
updateTask: vi.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => Object.assign(task, patch)),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
};
const result = await reviewStep(
"/tmp/worktree", "FN-4093", 2, "Retry", "plan", "# prompt", undefined,
{
store: store as any,
taskId: "FN-4093",
projectValidatorFallbackProvider: "openai",
projectValidatorFallbackModelId: "gpt-5-mini",
},
);
expect(result.verdict).toBe("APPROVE");
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
expect(store.updateTask).toHaveBeenCalledWith("FN-4093", { reviewerFallbackRetryCount: 0 });
expect(task.reviewerFallbackRetryCount).toBe(0);
});
it("does not reset reviewerFallbackRetryCount when fallback remains UNAVAILABLE", async () => {
mockedCreateFnAgent
.mockResolvedValueOnce(createMockSession("No parseable verdict #1"))
.mockResolvedValueOnce(createMockSession("No parseable verdict #2"));
const task = { id: "FN-4094", column: "in-progress", description: "d", dependencies: [], steps: [], currentStep: 0, log: [], prompt: "# prompt", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", reviewerFallbackRetryCount: 2 };
const store = {
getSettings: vi.fn().mockResolvedValue({ maxReviewerFallbackRetries: 8, maxTotalRetriesBeforeFail: 25 }),
getTask: vi.fn().mockImplementation(async () => task),
updateTask: vi.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => Object.assign(task, patch)),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
};
const result = await reviewStep(
"/tmp/worktree", "FN-4094", 2, "Retry", "plan", "# prompt", undefined,
{
store: store as any,
taskId: "FN-4094",
projectValidatorFallbackProvider: "openai",
projectValidatorFallbackModelId: "gpt-5-mini",
},
);
expect(result.verdict).toBe("UNAVAILABLE");
expect(store.updateTask).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ reviewerFallbackRetryCount: 0 }),
);
});
it("retries once after non-context reviewer error", async () => {

View File

@@ -662,6 +662,13 @@ export async function reviewStep(
const hasConfiguredFallback = Boolean(validatorFallbackProvider && validatorFallbackModelId);
const retrySettings = liveSettings ?? options.settings;
const resetReviewerFallbackRetryCount = async (): Promise<void> => {
if (!options.store || !options.taskId || typeof options.store.updateTask !== "function") {
return;
}
await options.store.updateTask(options.taskId, { reviewerFallbackRetryCount: 0 }).catch(() => undefined);
};
let firstAttempt: { verdict: ReviewVerdict; summary: string; review: string };
try {
firstAttempt = await runAttempt(request);
@@ -680,10 +687,14 @@ export async function reviewStep(
});
}
try {
return await runAttempt(request, {
const fallbackResult = await runAttempt(request, {
forceProvider: validatorFallbackProvider,
forceModelId: validatorFallbackModelId,
});
if (fallbackResult.verdict !== "UNAVAILABLE") {
await resetReviewerFallbackRetryCount();
}
return fallbackResult;
} catch {
throw err;
}
@@ -702,13 +713,18 @@ export async function reviewStep(
});
}
try {
return await runAttempt(fallbackReviewRequest);
const fallbackResult = await runAttempt(fallbackReviewRequest);
if (fallbackResult.verdict !== "UNAVAILABLE") {
await resetReviewerFallbackRetryCount();
}
return fallbackResult;
} catch {
throw err;
}
}
if (firstAttempt.verdict !== "UNAVAILABLE") {
await resetReviewerFallbackRetryCount();
return firstAttempt;
}
@@ -725,10 +741,14 @@ export async function reviewStep(
agentId: options.agentId,
});
}
return runAttempt(request, {
const fallbackResult = await runAttempt(request, {
forceProvider: validatorFallbackProvider,
forceModelId: validatorFallbackModelId,
});
if (fallbackResult.verdict !== "UNAVAILABLE") {
await resetReviewerFallbackRetryCount();
}
return fallbackResult;
}
await logFallbackRetry("UNAVAILABLE verdict", "same-model strict prompt");
@@ -743,7 +763,11 @@ export async function reviewStep(
agentId: options.agentId,
});
}
return runAttempt(fallbackReviewRequest);
const fallbackResult = await runAttempt(fallbackReviewRequest);
if (fallbackResult.verdict !== "UNAVAILABLE") {
await resetReviewerFallbackRetryCount();
}
return fallbackResult;
}
function isReviewerSessionReuseError(error: unknown): boolean {