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:
96
packages/engine/src/__tests__/retry-burned-logger.test.ts
Normal file
96
packages/engine/src/__tests__/retry-burned-logger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user