feat(FN-4398): complete Step 3 — wire retry-burned logging and caps

Fusion-Task-Id: FN-4398
Fusion-Task-Lineage: 8b898b2e-3468-4fa7-8546-b8f6ba52cf46
This commit is contained in:
Fusion
2026-05-14 14:55:31 -07:00
committed by gsxdsm
parent 831e4969e9
commit 9230c80fb0
5 changed files with 280 additions and 4 deletions

View File

@@ -0,0 +1,96 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { RetryStormError, type TaskDetail } from "@fusion/core";
import { recordRetry } from "../retry-burned-logger.js";
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-1",
lineageId: "lineage-1",
description: "desc",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
prompt: "prompt",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("recordRetry", () => {
const baseSettings = {
maxBranchConflictRecoveries: 5,
maxReviewerContextRetries: 2,
maxReviewerFallbackRetries: 2,
maxTotalRetriesBeforeFail: 25,
};
let task: TaskDetail;
let store: { updateTask: ReturnType<typeof vi.fn>; getTask: ReturnType<typeof vi.fn> };
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
beforeEach(() => {
task = makeTask();
store = {
updateTask: vi.fn(async (_id: string, patch: Record<string, number>) => {
Object.assign(task, patch);
}),
getTask: vi.fn(async () => task),
};
});
afterEach(() => {
consoleSpy.mockClear();
});
it("increments new-category counters and logs payload", async () => {
await recordRetry({
store: store as never,
settings: baseSettings,
task,
category: "reviewerContext",
role: "reviewer",
attempt: 1,
});
expect(task.reviewerContextRetryCount).toBe(1);
expect(store.updateTask).toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[retry-burned] retry-burned"),
expect.objectContaining({ taskId: "FN-1", category: "reviewerContext", attempt: 1, total: 1 }),
);
});
it("does not rewrite persisted counters when skipIncrement=true", async () => {
task.stuckKillCount = 3;
await recordRetry({
store: store as never,
settings: { ...baseSettings, maxTotalRetriesBeforeFail: 3 },
task,
category: "stuckKill",
role: "self-healing",
skipIncrement: true,
});
expect(task.stuckKillCount).toBe(3);
expect(store.updateTask).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalled();
});
it("throws RetryStormError when master cap is exceeded", async () => {
task.stuckKillCount = 26;
await expect(
recordRetry({
store: store as never,
settings: { ...baseSettings, maxTotalRetriesBeforeFail: 25 },
task,
category: "stuckKill",
role: "self-healing",
skipIncrement: true,
}),
).rejects.toBeInstanceOf(RetryStormError);
});
});

View File

@@ -314,8 +314,11 @@ describe("reviewStep — context-limit retry", () => {
},
} as any);
const task = { id: "FN-4082", 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", reviewerContextRetryCount: 0 };
const store = {
getSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ maxReviewerContextRetries: 2, 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),
};
@@ -359,6 +362,7 @@ describe("reviewStep — context-limit retry", () => {
"FN-4082",
"code review hit context limit — retrying with compacted request",
);
expect(task.reviewerContextRetryCount).toBe(1);
});
it("returns UNAVAILABLE when both attempts hit the context limit", async () => {
@@ -401,8 +405,11 @@ describe("reviewStep — fallback retry for terminal unavailable", () => {
.mockResolvedValueOnce(createMockSession("No parseable verdict here."))
.mockResolvedValueOnce(createMockSession("### Verdict: APPROVE\n### Summary\nRecovered on fallback."));
const task = { id: "FN-4092", 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: 0 };
const store = {
getSettings: vi.fn().mockResolvedValue({}),
getSettings: vi.fn().mockResolvedValue({ maxReviewerFallbackRetries: 2, 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),
};
@@ -423,6 +430,7 @@ describe("reviewStep — fallback retry for terminal unavailable", () => {
"FN-4092",
expect.stringContaining("review retry with fallback model after UNAVAILABLE verdict"),
);
expect(task.reviewerFallbackRetryCount).toBe(1);
});
it("retries once after non-context reviewer error", async () => {