Files
fusion/packages/engine/src/recovery-policy.ts
gsxdsm 8c246b036f feat(FN-775): add recoverable-retry with bounded exponential backoff
- Add  state to tasks: persisted in DB, gates scheduler pickup to prevent immediate retry of transient failures
- Introduce shared recovery-policy module with bounded exponential backoff (1s → 60s, max 5 attempts)
- Wire recovery policy into executor, scheduler, and triage so all agents respect the same retry cadence
- Persist retry state (attempt count, next eligible time) in task metadata via store and DB schema
- Add DB migration for new retry columns and update schema tests
- Update README with recovery policy documentation
- Refactor dashboard Header component and styles, consolidate header tests
- Fix session-files route tests to align with updated route signatures
2026-04-03 07:58:45 -07:00

126 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Recovery Policy — bounded exponential-backoff retry for recoverable executor/triage failures.
*
* This module provides a **pure decision function** that computes whether a transient
* failure should be retried, and if so, what the updated recovery state should be.
*
* **Design boundary:**
* - `recovery-policy.ts` handles **inter-poll** recoverable retries — tasks moved back
* to todo/triage with backoff, gated by `nextRecoveryAt` in the scheduler/triage poller.
* - `withRateLimitRetry()` in `rate-limit-retry.ts` handles **intra-session** rate-limit
* retries — immediate retry within the same agent session with exponential backoff.
* - `transient-error-detector.ts` provides the low-level error classifier (`isTransientError`,
* `classifyError`). This module consumes those classifiers but does not replace them.
*
* **Retry semantics:**
* - Up to `MAX_RECOVERY_RETRIES` attempts with exponential backoff.
* - Base delay: 60 seconds, multiplied by 2^attempt, capped at 300 seconds.
* - ±10% jitter to avoid thundering-herd effects.
* - Recovery metadata (`recoveryRetryCount`, `nextRecoveryAt`) is persisted on the task
* so retries survive engine restarts.
* - Exhausted retry budgets escalate to a real failure (task marked failed or error set).
*
* **Not retried via this policy:**
* - Usage-limit errors (handled by `UsageLimitPauser` with global pause)
* - User pauses (handled by pause flow)
* - Stuck-task-detector kills (handled by stuck flow)
* - Dependency-abort cleanups (handled by dep-abort flow)
* - Merge-conflict retries (handled by `mergeRetries` separately)
*/
// ── Constants ────────────────────────────────────────────────────────
/** Maximum number of recovery retry attempts before escalating to failure. */
export const MAX_RECOVERY_RETRIES = 3;
/** Base delay in milliseconds for the first retry (60 seconds). */
export const BASE_DELAY_MS = 60_000;
/** Maximum delay cap in milliseconds (300 seconds = 5 minutes). */
export const MAX_DELAY_MS = 300_000;
/** Backoff multiplier (2x exponential). */
export const BACKOFF_MULTIPLIER = 2;
// ── Types ────────────────────────────────────────────────────────────
export interface RecoveryState {
recoveryRetryCount?: number;
nextRecoveryAt?: string;
}
export interface RecoveryDecision {
/** Whether the task should be retried (moved back to todo/triage). */
shouldRetry: boolean;
/** Whether the retry budget is exhausted (terminal failure). */
exhausted: boolean;
/** Updated recovery state to persist on the task. */
nextState: RecoveryState;
/** Computed delay in milliseconds (for logging). Zero when exhausted. */
delayMs: number;
}
// ── Decision function ────────────────────────────────────────────────
/**
* Compute whether a recoverable failure should be retried and what the
* updated recovery state should be.
*
* This is a **pure function** — it does not call TaskStore or perform I/O.
* The caller is responsible for persisting `nextState` via `store.updateTask()`.
*
* @param currentState - Current recovery metadata from the task
* @returns A decision describing whether to retry or escalate
*/
export function computeRecoveryDecision(
currentState: RecoveryState,
): RecoveryDecision {
const currentCount = currentState.recoveryRetryCount ?? 0;
const nextCount = currentCount + 1;
if (nextCount > MAX_RECOVERY_RETRIES) {
// Budget exhausted — escalate to real failure
return {
shouldRetry: false,
exhausted: true,
nextState: { recoveryRetryCount: undefined, nextRecoveryAt: undefined },
delayMs: 0,
};
}
// Exponential backoff: base × 2^(attempt-1), capped at max
const rawDelay = Math.min(
BASE_DELAY_MS * BACKOFF_MULTIPLIER ** (nextCount - 1),
MAX_DELAY_MS,
);
// ±10% jitter to avoid thundering herd
const jitter = rawDelay * 0.1 * (2 * Math.random() - 1);
const delayMs = Math.max(0, Math.round(rawDelay + jitter));
const nextRecoveryAt = new Date(Date.now() + delayMs).toISOString();
return {
shouldRetry: true,
exhausted: false,
nextState: {
recoveryRetryCount: nextCount,
nextRecoveryAt,
},
delayMs,
};
}
/**
* Format a retry delay for human-readable logging.
*
* @param delayMs - Delay in milliseconds
* @returns Human-readable string like "60s" or "120s"
*/
export function formatDelay(delayMs: number): string {
const seconds = Math.round(delayMs / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
return seconds % 60 === 0 ? `${minutes}m` : `${seconds}s`;
}