fix(FN-8006): back off and pause Plan Review on provider rate limits

A rate-limited Plan Review re-ran every 30s for hours (~1,900 requests
per 5h window, reviewerFallbackRetryCount observed past 100), which is
the request volume that trips a provider's low-interactivity throttle —
so the retry storm prolonged the very outage it was retrying.

Root cause: runPlanReviewBeforeExecution catches every reviewStep throw
inline to keep triage alive, which converts them all to an UNAVAILABLE
verdict. That laundering had two consequences the earlier fixes missed:
FN-8006 terminalized RetryStormError and the reviewer started throwing
ReviewerProviderError for 429s, but a ReviewerProviderError still landed
in the UNAVAILABLE park — a FIXED 30s nextRecoveryAt with no attempt
counter and no cap. The reviewer's own escalation contract ("escalate so
UsageLimitPauser pauses every lane") held only on the executor path,
because the inline catch hid the error from triage's usage-limit handler
in specifyTask.

- triage: fire usageLimitPauser.onUsageLimitHit for usage-limit reviewer
  failures, so a 429 pauses every lane instead of re-parking one task.
- triage: re-park via computeRecoveryDecision (60s/120s/240s, ±10%
  jitter) and terminalize at MAX_RECOVERY_RETRIES. A reviewer that never
  yields a verdict is a real failure and must surface, not spin.
- triage: clear the borrowed recoveryRetryCount budget on any real
  verdict, so surviving an outage cannot shorten the executor's later
  transient budget.
- core: RetryStormError takes an optional cause, surfaced as
  underlyingError in serializeRetryStormError and folded into the
  message, so a cap no longer masks the real error. recordRetry threads
  it from the reviewer's error path.

Surface enumeration: the park is driven by a thrown provider error, a
thrown generic error, and a plain UNAVAILABLE verdict with no throw.
All three are covered — a repro pinned only to the reported 429 would
leave the other two spinning on the old fixed timer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-15 20:53:05 -07:00
parent 130c70286b
commit 08a10bf486
9 changed files with 314 additions and 11 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Plan Review now backs off and pauses on provider rate limits instead of retrying every 30s for hours.
category: fix
dev: runPlanReviewBeforeExecution fires usageLimitPauser.onUsageLimitHit for usage-limit reviewer failures (the inline reviewStep catch hid them from triage's own handler) and re-parks via computeRecoveryDecision (60s/120s/240s, terminalizing at MAX_RECOVERY_RETRIES) instead of a fixed 30s nextRecoveryAt. The borrowed recoveryRetryCount budget is cleared on any real verdict. RetryStormError gains an optional cause, surfaced as underlyingError in serializeRetryStormError, so the cap no longer masks the real error.

View File

@@ -2176,7 +2176,16 @@ UI contract boundary:
Fusion derives a per-task `retrySummary` at read time by aggregating retry counters (stuck-kill, recovery, task_done, workflow-step, verification, post-review-fix, merge-conflict bounce, branch-conflict recovery, reviewer context retry, reviewer fallback retry). The engine emits a structured `retry-burned` log channel with `{ taskId, agentId, role, category, attempt, total, breakdown }` so token-cost telemetry can correlate retry burn with spend.
Project settings expose per-category caps (`maxBranchConflictRecoveries`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`) plus a master cap (`maxTotalRetriesBeforeFail`). When a cap is exceeded, engine code throws `RetryStormError`; executor and triage Plan Review terminal failure handling serialize this into `task.error` so dashboard surfaces can render structured failure details. Plan Review must terminalize this guard rather than re-queue `plan-review-unavailable`, which would otherwise continue burning the reviewer-fallback budget.
Project settings expose per-category caps (`maxBranchConflictRecoveries`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries`) plus a master cap (`maxTotalRetriesBeforeFail`). When a cap is exceeded, engine code throws `RetryStormError`; executor and triage Plan Review terminal failure handling serialize this into `task.error` so dashboard surfaces can render structured failure details. Plan Review must terminalize this guard rather than re-queue `plan-review-unavailable`, which would otherwise continue burning the reviewer-fallback budget. Callers that hold the failure which burned the final retry pass it to `recordRetry({ cause })`; it surfaces as `underlyingError` in `serializeRetryStormError` so a cap never masks the real error (e.g. `429: overloaded_error`).
### Plan Review provider failures must back off and pause (FN-8006)
`reviewStep` throws `ReviewerProviderError` for usage-limit/transient provider failures so they never launder into an `UNAVAILABLE` verdict, but `runPlanReviewBeforeExecution` catches every throw inline to keep triage alive — which means provider failures still arrive at the `plan-review-unavailable` park. Two rules bound that park; both are load-bearing and neither is optional:
1. **Usage limits pause every lane.** The inline catch hides the error from triage's own `isUsageLimitError` handler in `specifyTask`, so the Plan Review path fires `usageLimitPauser.onUsageLimitHit` itself. Without this, `reviewer.ts`'s escalation contract ("escalate so `UsageLimitPauser` pauses every lane") holds only on the executor path.
2. **Every re-park uses bounded backoff.** Parks go through `computeRecoveryDecision` (60s → 120s → 240s, ±10% jitter) and terminalize at `MAX_RECOVERY_RETRIES`. The park previously used a fixed 30s `nextRecoveryAt` with no attempt counter, so a sustained outage re-ran Plan Review every 30s for hours (~1,900 requests/5h observed) — the volume that trips a provider's low-interactivity throttle and prolongs the outage being retried.
Plan Review borrows the shared `recoveryRetryCount` transient-triage budget for this backoff and clears it on any real verdict (APPROVE/REVISE/RETHINK); a stale count would otherwise shorten the executor's later transient budget for a task whose only fault was surviving a reviewer outage.
## Lifecycle invariants

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { RetryStormError, serializeRetryStormError } from "../retry-storm-error.js";
import type { RetrySummary } from "../types.js";
const BREAKDOWN: RetrySummary = {
stuckKill: 0,
recovery: 0,
taskDone: 0,
worktreeSession: 0,
workflowStep: 0,
verification: 0,
postReviewFix: 0,
mergeConflict: 0,
branchConflict: 0,
reviewerContext: 0,
reviewerFallback: 3,
total: 3,
};
function makeStorm(cause?: unknown): RetryStormError {
return new RetryStormError({ category: "reviewerFallback", total: 3, cap: 2, breakdown: BREAKDOWN, cause });
}
/**
* FNXC:RetryStorm 2026-07-15-21:30:
* The storm message used to REPLACE the real error from the cap onward, so "the provider is down"
* and "the reviewer keeps failing for other reasons" were indistinguishable in logs and task.error.
*/
describe("RetryStormError", () => {
it("keeps the underlying error in the message, the field, and the native cause", () => {
const cause = new Error("429: overloaded_error");
const err = makeStorm(cause);
expect(err.message).toContain("Retry storm: 3 retries exceeds cap 2");
expect(err.message).toContain("429: overloaded_error");
expect(err.underlyingError).toBe("429: overloaded_error");
expect(err.cause).toBe(cause);
});
it("stringifies a non-Error cause", () => {
expect(makeStorm("plain string failure").underlyingError).toBe("plain string failure");
});
it("stays silent about a cause it was never given", () => {
const err = makeStorm();
expect(err.message).toBe("Retry storm: 3 retries exceeds cap 2 (top category: reviewerFallback)");
expect(err.underlyingError).toBeUndefined();
expect(serializeRetryStormError(err)).not.toHaveProperty("underlyingError");
});
it("serializes the underlying error for structured surfaces", () => {
expect(serializeRetryStormError(makeStorm(new Error("429: overloaded_error")))).toEqual({
type: "RetryStormError",
category: "reviewerFallback",
total: 3,
cap: 2,
breakdown: BREAKDOWN,
underlyingError: "429: overloaded_error",
});
});
});

View File

@@ -1,5 +1,18 @@
import type { RetrySummary } from "./types.js";
/*
FNXC:RetryStorm 2026-07-15-21:30:
A retry storm reports that a budget ran out, which is never the whole story — the operator's
next question is always "ran out because WHAT kept failing?". The bare message ("Retry storm: N
retries exceeds cap M") answered the first and hid the second: from the cap onward it replaced
the real error (e.g. `429: overloaded_error`) in logs and `task.error`, so "the provider is down"
and "the reviewer keeps producing bad reviews" looked identical at the point of failure.
Callers that hold the failure which burned the final retry pass it as `cause`. It is folded into
the message, kept as `underlyingError` for structured surfaces, and preserved as the native Error
`cause` for stack-walking. Optional on purpose: some budgets (an UNAVAILABLE verdict) are burned
by a bad result rather than a thrown error, and have nothing meaningful to attach.
*/
export class RetryStormError extends Error {
readonly category: string;
@@ -9,13 +22,30 @@ export class RetryStormError extends Error {
readonly breakdown: RetrySummary;
constructor({ category, total, cap, breakdown }: { category: string; total: number; cap: number; breakdown: RetrySummary }) {
super(`Retry storm: ${total} retries exceeds cap ${cap} (top category: ${category})`);
/** Message of the failure that burned the final retry, when the caller had one in hand. */
readonly underlyingError?: string;
constructor({ category, total, cap, breakdown, cause }: {
category: string;
total: number;
cap: number;
breakdown: RetrySummary;
cause?: unknown;
}) {
const underlyingError = cause === undefined || cause === null
? undefined
: (cause instanceof Error ? cause.message : String(cause));
super(
`Retry storm: ${total} retries exceeds cap ${cap} (top category: ${category})`
+ (underlyingError ? ` — last error: ${underlyingError}` : ""),
cause === undefined ? undefined : { cause },
);
this.name = "RetryStormError";
this.category = category;
this.total = total;
this.cap = cap;
this.breakdown = breakdown;
this.underlyingError = underlyingError;
}
}
@@ -25,6 +55,7 @@ export function serializeRetryStormError(err: RetryStormError): {
total: number;
cap: number;
breakdown: RetrySummary;
underlyingError?: string;
} {
return {
type: "RetryStormError",
@@ -32,5 +63,6 @@ export function serializeRetryStormError(err: RetryStormError): {
total: err.total,
cap: err.cap,
breakdown: err.breakdown,
...(err.underlyingError === undefined ? {} : { underlyingError: err.underlyingError }),
};
}

View File

@@ -97,12 +97,23 @@ async function writePrompt(rootDir: string, taskId: string, prompt: string): Pro
return promptPath;
}
async function retryTask(rootDir: string, task: Task, store = createStore(task)): Promise<TaskStore> {
const processor = new TriageProcessor(store, rootDir);
async function retryTask(
rootDir: string,
task: Task,
store = createStore(task),
options: ConstructorParameters<typeof TriageProcessor>[2] = {},
): Promise<TaskStore> {
const processor = new TriageProcessor(store, rootDir, options);
await processor.specifyTask(task);
return store;
}
function findUpdate(store: TaskStore, taskId: string, predicate: (update: Record<string, unknown>) => boolean) {
return (store.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(
([id, update]) => id === taskId && update && predicate(update as Record<string, unknown>),
)?.[1] as Record<string, unknown> | undefined;
}
describe("Plan Review unavailable retry", () => {
let roots: string[] = [];
@@ -209,6 +220,108 @@ describe("Plan Review unavailable retry", () => {
);
});
/**
* FNXC:PlanReview 2026-07-15-21:30:
* A sustained provider outage used to re-run Plan Review on a FIXED 30s timer with no attempt
* counter — ~1,900 requests/5h, which is what trips a provider's low-interactivity throttle.
* These regressions pin the two rules that bound it: the backoff grows and terminalizes, and a
* usage limit pauses every lane rather than only re-parking this task.
*
* Surface enumeration — the loop is driven by (a) a thrown provider error, (b) a thrown generic
* error, and (c) a plain UNAVAILABLE verdict with no throw at all. All three enter the same park,
* so all three are asserted; a repro that only covered the reported 429 would leave (b) and (c)
* spinning on the old fixed timer.
*/
it.each([
{ name: "thrown 429 overloaded_error", setup: () => mockReviewStep.mockRejectedValue(new Error("429 overloaded_error")) },
{ name: "thrown generic reviewer error", setup: () => mockReviewStep.mockRejectedValue(new Error("review process crashed")) },
{ name: "plain unavailable verdict", setup: () => mockReviewStep.mockResolvedValue({ verdict: "UNAVAILABLE", review: "No verdict.", summary: "Unavailable." }) },
])("backs off exponentially rather than on a fixed 30s timer for $name", async ({ setup }) => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
// Second attempt: 60s base × 2^1 = 120s ±10% — provably past the old fixed 30s park.
const task = createRetryTask({ id: "FN-PLAN-BACKOFF", recoveryRetryCount: 1 });
await writePrompt(rootDir, task.id, `# Task: ${task.id}\n\n## Mission\n\nKeep me.\n`);
const store = createStore(task);
setup();
const before = Date.now();
await retryTask(rootDir, task, store);
const park = findUpdate(store, task.id, (u) => u.status === "plan-review-unavailable");
expect(park?.recoveryRetryCount).toBe(2);
const delayMs = new Date(park?.nextRecoveryAt as string).getTime() - before;
expect(delayMs).toBeGreaterThan(60_000);
expect(delayMs).toBeLessThanOrEqual(133_000);
});
it("pauses every lane when Plan Review hits a provider usage limit", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-PLAN-429" });
await writePrompt(rootDir, task.id, `# Task: ${task.id}\n\n## Mission\n\nKeep me.\n`);
const store = createStore(task);
const onUsageLimitHit = vi.fn().mockResolvedValue(undefined);
mockReviewStep.mockRejectedValue(new Error("429 overloaded_error"));
await retryTask(rootDir, task, store, { usageLimitPauser: { onUsageLimitHit } as never });
expect(onUsageLimitHit).toHaveBeenCalledWith("triage", task.id, expect.stringContaining("429"));
});
it("does not pause lanes for a reviewer failure that is not a usage limit", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-PLAN-NO-PAUSE" });
await writePrompt(rootDir, task.id, `# Task: ${task.id}\n\n## Mission\n\nKeep me.\n`);
const store = createStore(task);
const onUsageLimitHit = vi.fn().mockResolvedValue(undefined);
mockReviewStep.mockRejectedValue(new Error("review process crashed"));
await retryTask(rootDir, task, store, { usageLimitPauser: { onUsageLimitHit } as never });
expect(onUsageLimitHit).not.toHaveBeenCalled();
});
it("terminalizes instead of re-parking once the unavailable retry budget is spent", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
// MAX_RECOVERY_RETRIES is 3, so a 3rd spent attempt exhausts on this pass.
const task = createRetryTask({ id: "FN-PLAN-EXHAUSTED", recoveryRetryCount: 3 });
await writePrompt(rootDir, task.id, `# Task: ${task.id}\n\n## Mission\n\nKeep me.\n`);
const store = createStore(task);
mockReviewStep.mockRejectedValue(new Error("429 overloaded_error"));
await retryTask(rootDir, task, store);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "failed",
// The real error must survive to task.error — "budget exhausted" alone hides the cause.
error: expect.stringContaining("429 overloaded_error"),
recoveryRetryCount: null,
nextRecoveryAt: null,
}));
expect(store.updateTask).not.toHaveBeenCalledWith(task.id, expect.objectContaining({
status: "plan-review-unavailable",
}));
});
it("returns the borrowed recovery budget once Plan Review produces a real verdict", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);
const task = createRetryTask({ id: "FN-PLAN-BUDGET-CLEAR", recoveryRetryCount: 2 });
await writePrompt(rootDir, task.id, `# Task: ${task.id}\n\n## Mission\n\nKeep me.\n`);
const store = createStore(task);
mockReviewStep.mockResolvedValue({ verdict: "APPROVE", review: "Approved.", summary: "Ready." });
await retryTask(rootDir, task, store);
expect(store.updateTask).toHaveBeenCalledWith(task.id, expect.objectContaining({
recoveryRetryCount: null,
nextRecoveryAt: null,
}));
});
it("terminalizes a reviewer retry storm instead of scheduling another unavailable retry", async () => {
const rootDir = await createFixtureRoot();
roots.push(rootDir);

View File

@@ -1655,9 +1655,12 @@ Planner rewrote mission without the raw request.
);
expect(store.moveTask).not.toHaveBeenCalled();
// FNXC:PlanReview 2026-07-15-21:30: the park now carries the bounded attempt/delay and the
// underlying reviewer error, replacing the old fixed-30s "retrying from triage." copy.
expect(store.updateTask).toHaveBeenCalledWith("FN-PLAN-UNAVAILABLE", expect.objectContaining({
status: "plan-review-unavailable",
error: "Plan Review did not produce a verdict; retrying from triage.",
error: expect.stringContaining("retry 1/3 in"),
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
expect(store.updateTask).toHaveBeenCalledWith("FN-PLAN-UNAVAILABLE", expect.objectContaining({
@@ -1996,9 +1999,11 @@ Planner rewrote mission without the raw request.
);
expect(readFileSync(promptPath, "utf-8")).toBe(prompt);
expect(retryStore.moveTask).not.toHaveBeenCalled();
// FNXC:PlanReview 2026-07-15-21:30: bounded backoff copy replaces the old fixed-30s park text.
expect(retryStore.updateTask).toHaveBeenCalledWith(taskId, expect.objectContaining({
status: "plan-review-unavailable",
error: "Plan Review did not produce a verdict; retrying from triage.",
error: expect.stringContaining("retry 1/3 in"),
recoveryRetryCount: 1,
nextRecoveryAt: expect.any(String),
}));
const finalStatusUpdate = (retryStore.updateTask as ReturnType<typeof vi.fn>).mock.calls.find(

View File

@@ -58,8 +58,11 @@ export async function recordRetry(options: {
agentId?: string;
attempt?: number;
skipIncrement?: boolean;
/** The failure that burned this retry, when the caller has one. Folded into any
* RetryStormError so the cap does not mask what actually kept failing. */
cause?: unknown;
}): Promise<TaskDetail> {
const { store, settings, task, category, role, agentId, attempt, skipIncrement } = options;
const { store, settings, task, category, role, agentId, attempt, skipIncrement, cause } = options;
const column = CATEGORY_COLUMN[category];
if (!skipIncrement) {
@@ -89,6 +92,7 @@ export async function recordRetry(options: {
total: breakdown.total,
cap: categoryCap,
breakdown,
cause,
});
}
@@ -98,6 +102,7 @@ export async function recordRetry(options: {
total: breakdown.total,
cap: totalCap,
breakdown,
cause,
});
}

View File

@@ -695,6 +695,7 @@ export async function reviewStep(
category: "reviewerFallback",
role: "reviewer",
agentId: options.agentId,
cause: err,
});
}
try {
@@ -721,6 +722,7 @@ export async function reviewStep(
category: "reviewerFallback",
role: "reviewer",
agentId: options.agentId,
cause: err,
});
}
try {

View File

@@ -2400,6 +2400,7 @@ export class TriageProcessor {
await this.store.updateTask(task.id, {
status: "failed",
error: terminalError,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
return "blocked";
@@ -2422,6 +2423,7 @@ export class TriageProcessor {
if ((task.planReviewReplanCount ?? 0) > 0) {
await this.store.updateTask(task.id, { planReviewReplanCount: null });
}
await this.clearPlanReviewRecoveryBudget(task);
await this.store.logEntry(task.id, "[pre-merge] Workflow step completed: Plan Review", review.summary);
return "approved";
}
@@ -2439,6 +2441,7 @@ export class TriageProcessor {
completedAt,
});
await this.store.logEntry(task.id, "[pre-merge] Workflow step failed: Plan Review", review.review);
await this.clearPlanReviewRecoveryBudget(task);
const reviseFeedback = review.review || review.summary || "(no feedback captured)";
await this.store.logEntry(
task.id,
@@ -2452,9 +2455,19 @@ export class TriageProcessor {
/*
FNXC:PlanReview 2026-06-29-02:40:
UNAVAILABLE means the reviewer session did not produce a usable verdict. Keep the task in triage and retry with backoff; do not fabricate a REVISE or send the planner through another full rewrite loop when no reviewer actually rejected the plan.
FNXC:PlanReview 2026-07-15-21:30:
Reviewer PROVIDER failures (429/`overloaded_error`, dropped sockets) also land here, because the `reviewStep` catch above converts every throw — including `ReviewerProviderError` — into an UNAVAILABLE verdict. That laundering used to strand the task on a FIXED 30s re-park with no attempt counter, so a sustained provider outage re-ran Plan Review every 30s for hours (~1,900 requests/5h observed), which is the request volume that trips a provider's low-interactivity throttle and thereby prolongs the very outage being retried. Two rules prevent the storm:
1. A usage-limit failure must reach `UsageLimitPauser` so EVERY lane pauses, not just this task. The inline catch swallows the throw before triage's own usage-limit handler in `specifyTask` can see it, so this path fires the pauser itself. Without this, `reviewer.ts`'s escalation promise ("escalate so UsageLimitPauser pauses every lane") held only on the executor path.
2. Every re-park goes through the bounded `computeRecoveryDecision` backoff (60s → 120s → 240s, ±10% jitter) and terminalizes when the budget is spent. A reviewer that never yields a verdict is a real failure and must surface, not spin — the old park had neither backoff nor cap.
`recoveryRetryCount` is the shared transient-triage budget this gate borrows; `clearPlanReviewRecoveryBudget` clears it on any real verdict so a task that survived a reviewer outage does not carry a spent budget into execution.
*/
const retryAt = new Date(Date.now() + 30_000).toISOString();
const unavailableOutput = review.review || review.summary || "Plan Review was unavailable before producing a verdict.";
const unavailableError = reviewFailure instanceof Error
? reviewFailure.message
: (reviewFailure === undefined ? "" : String(reviewFailure));
await this.recordPlanReviewWorkflowResult(task, {
workflowStepId: PLAN_REVIEW_GROUP_ID,
workflowStepName: "Plan Review",
@@ -2466,14 +2479,68 @@ export class TriageProcessor {
completedAt,
});
await this.store.logEntry(task.id, "[pre-merge] Workflow step unavailable: Plan Review", unavailableOutput);
if (this.options.usageLimitPauser && unavailableError && isUsageLimitError(unavailableError)) {
planLog.warn(`${task.id}: Plan Review hit a provider usage limit — pausing all lanes: ${unavailableError}`);
await this.options.usageLimitPauser.onUsageLimitHit("triage", task.id, unavailableError).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to signal Plan Review usage limit to the pauser: ${msg}`);
});
}
const decision = computeRecoveryDecision({
recoveryRetryCount: task.recoveryRetryCount,
nextRecoveryAt: task.nextRecoveryAt,
});
const errorSuffix = unavailableError ? `: ${unavailableError}` : "";
if (!decision.shouldRetry) {
const terminalError =
`Plan Review did not produce a verdict after ${MAX_RECOVERY_RETRIES} retries${errorSuffix}`;
planLog.error(`✗ ${task.id} Plan Review retry budget exhausted${errorSuffix}`);
await this.store.logEntry(task.id, terminalError).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to log Plan Review retry exhaustion: ${msg}`);
});
await this.store.updateTask(task.id, {
status: "failed",
error: terminalError,
recoveryRetryCount: null,
nextRecoveryAt: null,
});
return "blocked";
}
const attempt = decision.nextState.recoveryRetryCount ?? 1;
const delay = formatDelay(decision.delayMs);
planLog.warn(`⚡ ${task.id} Plan Review unavailable — retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}${errorSuffix}`);
await this.store.updateTask(task.id, {
status: "plan-review-unavailable",
error: "Plan Review did not produce a verdict; retrying from triage.",
nextRecoveryAt: retryAt,
error: `Plan Review did not produce a verdict; retry ${attempt}/${MAX_RECOVERY_RETRIES} in ${delay}${errorSuffix}`,
recoveryRetryCount: decision.nextState.recoveryRetryCount,
nextRecoveryAt: decision.nextState.nextRecoveryAt,
});
return "blocked";
}
/*
FNXC:PlanReview 2026-07-15-21:30:
A real Plan Review verdict proves the reviewer is reachable, so the borrowed transient-triage
recovery budget must go back to full. Leaving a spent `recoveryRetryCount` behind would shorten
— or immediately exhaust — the executor's own transient budget for a task whose only sin was
surviving a reviewer outage.
*/
private async clearPlanReviewRecoveryBudget(task: Task): Promise<void> {
if ((task.recoveryRetryCount ?? 0) === 0 && !task.nextRecoveryAt) return;
await this.store.updateTask(task.id, {
recoveryRetryCount: null,
nextRecoveryAt: null,
}).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
planLog.warn(`${task.id}: failed to clear Plan Review recovery budget: ${msg}`);
});
}
private async tryFinalizeExplicitDuplicateMarker(
task: Task,
written: string,