compaction & branch summarization follow retry policy
fixes #6647 compaction (auto & manual) and branch summarization retry on transient failures. use the same retry policy from settings. emit events for the tui to show indication of retries
This commit is contained in:
@@ -85,6 +85,116 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
"ResourceExhausted",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Retry policy: bounded attempts with exponential backoff (`baseDelayMs * 2^(attempt-1)`).
|
||||
* Matches `settings.retry` (`enabled`, `maxRetries`, `baseDelayMs`) in coding-agent; kept
|
||||
* here so the classifier and the policy-driven retry loop live together and stay reusable
|
||||
* by the SDK and other callers.
|
||||
*/
|
||||
export interface RetryPolicy {
|
||||
enabled: boolean;
|
||||
/** Max retry attempts (0 = no retries). The initial call never counts as a retry. */
|
||||
maxRetries: number;
|
||||
/** Base delay in ms. Per-attempt delay is `baseDelayMs * 2^(attempt-1)` before jitter. */
|
||||
baseDelayMs: number;
|
||||
}
|
||||
|
||||
/** Optional callbacks emitted by {@link retryAssistantCall} around each retry. */
|
||||
export interface RetryCallbacks {
|
||||
/** Emitted before the backoff sleep of each retry attempt (1-indexed). */
|
||||
onRetry?: (attempt: number, maxAttempts: number, delayMs: number, errorMessage: string) => void;
|
||||
/** Emitted after the backoff sleep, immediately before the retried call starts. */
|
||||
onRetryAttemptStart?: () => void;
|
||||
/** Emitted once when the loop ends: success if a later call returned a non-error message. */
|
||||
onRetryEnd?: (success: boolean, attempt: number, finalError?: string) => void;
|
||||
}
|
||||
|
||||
class RetrySleepAbortError extends Error {
|
||||
constructor() {
|
||||
super("Aborted");
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new RetrySleepAbortError());
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new RetrySleepAbortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single assistant-producing call with bounded retry on transient errors.
|
||||
*
|
||||
* Behavior:
|
||||
* - A non-error response (success or aborted) is returned immediately; aborts are
|
||||
* never retried. Aborts during the backoff sleep are normalized to an aborted
|
||||
* `AssistantMessage` too, so callers do not need to care when cancellation happened.
|
||||
* - A non-retryable error (per {@link isRetryableAssistantError}, including quota/
|
||||
* billing exhaustion) is returned immediately so deterministic errors fail fast.
|
||||
* - Otherwise retries up to `maxRetries` times with exponential backoff, emitting
|
||||
* `onRetry` before each sleep, `onRetryAttemptStart` after each sleep before the
|
||||
* retried call starts, and `onRetryEnd` once at the end (whether the loop ends in
|
||||
* success, exhausted retries, or an aborted backoff).
|
||||
*
|
||||
* When `policy` is undefined or disabled, the first response is returned unchanged
|
||||
* (equivalent to calling `produce()` directly).
|
||||
*/
|
||||
export async function retryAssistantCall(
|
||||
produce: () => Promise<AssistantMessage>,
|
||||
policy: RetryPolicy | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
callbacks?: RetryCallbacks,
|
||||
): Promise<AssistantMessage> {
|
||||
const maxAttempts = policy?.enabled ? policy.maxRetries : 0;
|
||||
|
||||
let attempt = 0;
|
||||
let lastRetry: { attempt: number; errorMessage: string } | undefined;
|
||||
for (;;) {
|
||||
const response = await produce();
|
||||
|
||||
// Success or abort: never retry an aborted message; non-error returns as-is.
|
||||
if (response.stopReason !== "error") {
|
||||
if (lastRetry) callbacks?.onRetryEnd?.(true, lastRetry.attempt);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Non-retryable, or budget exhausted: return the final error message.
|
||||
if (attempt >= maxAttempts || !isRetryableAssistantError(response)) {
|
||||
if (lastRetry) callbacks?.onRetryEnd?.(false, lastRetry.attempt, response.errorMessage);
|
||||
return response;
|
||||
}
|
||||
|
||||
attempt++;
|
||||
lastRetry = { attempt, errorMessage: response.errorMessage || "Unknown error" };
|
||||
const delayMs = policy!.baseDelayMs * 2 ** (attempt - 1);
|
||||
callbacks?.onRetry?.(attempt, maxAttempts, delayMs, lastRetry.errorMessage);
|
||||
|
||||
// Normalize aborts during retry backoff to the same AssistantMessage shape as
|
||||
// provider stream aborts, so callers do not need to care when cancellation happened.
|
||||
try {
|
||||
await sleep(delayMs, signal);
|
||||
} catch (error) {
|
||||
callbacks?.onRetryEnd?.(false, attempt, lastRetry.errorMessage);
|
||||
if (error instanceof RetrySleepAbortError) {
|
||||
return { ...response, stopReason: "aborted", errorMessage: undefined };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
callbacks?.onRetryAttemptStart?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies whether a failed assistant message looks like a transient provider
|
||||
* or transport error, so callers can decide if the last assistant turn should be
|
||||
|
||||
Reference in New Issue
Block a user