import type { BackgroundTask, LaunchInput } from "./types" import type { FallbackEntry } from "../../shared/model-requirements" import type { ConcurrencyManager } from "./concurrency" import type { OpencodeClient, QueueItem } from "./constants" import { log, readConnectedProvidersCache, readProviderModelsCache } from "../../shared" import { shouldRetryError, getNextFallback, hasMoreFallbacks, selectFallbackProvider, } from "../../shared/model-error-classifier" import { transformModelForProvider } from "../../shared/provider-model-id-transform" import { abortWithTimeout } from "./abort-with-timeout" import { ensureCurrentAttempt, scheduleRetryAttempt } from "./attempt-lifecycle" function canonicalizeModelID(modelID: string): string { return modelID.toLowerCase().replace(/\./g, "-") } export async function tryFallbackRetry(args: { task: BackgroundTask errorInfo: { name?: string; message?: string } source: string concurrencyManager: ConcurrencyManager client: OpencodeClient idleDeferralTimers: Map> queuesByKey: Map processKey: (key: string) => void onRetrying?: (details: { task: BackgroundTask source: string previousSessionID?: string failedModel?: string failedError?: string nextModel: string }) => void }): Promise { const { task, errorInfo, source, concurrencyManager, client, idleDeferralTimers, queuesByKey, processKey, onRetrying } = args const fallbackChain = task.fallbackChain const canRetry = shouldRetryError(errorInfo) && fallbackChain && fallbackChain.length > 0 && hasMoreFallbacks(fallbackChain, task.attemptCount ?? 0) if (!canRetry) return false const attemptCount = task.attemptCount ?? 0 const providerModelsCache = readProviderModelsCache() const connectedProviders = providerModelsCache?.connected ?? readConnectedProvidersCache() const connectedSet = connectedProviders ? new Set(connectedProviders.map(p => p.toLowerCase())) : null const preferredProvider = task.model?.providerID?.toLowerCase() const isReachable = (entry: FallbackEntry): boolean => { if (!connectedSet) return true if (entry.providers.some((provider) => connectedSet.has(provider.toLowerCase()))) { return true } return preferredProvider ? connectedSet.has(preferredProvider) : false } let selectedAttemptCount = attemptCount let nextFallback: FallbackEntry | undefined let nextProviderID: string | undefined while (fallbackChain && selectedAttemptCount < fallbackChain.length) { const candidate = getNextFallback(fallbackChain, selectedAttemptCount) if (!candidate) break selectedAttemptCount++ if (!isReachable(candidate)) { log("[background-agent] Skipping unreachable fallback:", { taskId: task.id, source, model: candidate.model, providers: candidate.providers, }) continue } const candidateProviderID = selectFallbackProvider( candidate.providers, task.model?.providerID, ) const candidateModelID = transformModelForProvider(candidateProviderID, candidate.model) const isNoOpFallback = !!task.model && candidateProviderID.toLowerCase() === task.model.providerID.toLowerCase() && canonicalizeModelID(candidateModelID) === canonicalizeModelID(task.model.modelID) if (isNoOpFallback) { log("[background-agent] Skipping no-op fallback:", { taskId: task.id, source, model: candidate.model, providers: candidate.providers, }) continue } nextFallback = candidate nextProviderID = candidateProviderID break } if (!nextFallback) return false const providerID = nextProviderID ?? selectFallbackProvider( nextFallback.providers, task.model?.providerID, ) log("[background-agent] Retryable error, attempting fallback:", { taskId: task.id, source, errorName: errorInfo.name, errorMessage: errorInfo.message?.slice(0, 100), attemptCount: selectedAttemptCount, nextModel: `${providerID}/${nextFallback.model}`, }) if (task.concurrencyKey) { concurrencyManager.release(task.concurrencyKey) task.concurrencyKey = undefined } const idleTimer = idleDeferralTimers.get(task.id) if (idleTimer) { clearTimeout(idleTimer) idleDeferralTimers.delete(task.id) } const previousSessionID = task.sessionID const previousModel = task.model const transformedModelId = transformModelForProvider(providerID, nextFallback.model) const nextModel = { providerID, modelID: transformedModelId, variant: nextFallback.variant, } task.attemptCount = selectedAttemptCount const failedAttemptID = ensureCurrentAttempt(task, previousModel).attemptID const nextAttempt = failedAttemptID ? scheduleRetryAttempt(task, failedAttemptID, nextModel, errorInfo.message) : undefined if (!nextAttempt) { return false } task.queuedAt = new Date() task.retryNotification = { previousSessionID, failedModel: previousModel ? `${previousModel.providerID}/${previousModel.modelID}` : undefined, failedError: errorInfo.message, nextModel: `${providerID}/${transformedModelId}`, } onRetrying?.({ task, source, previousSessionID, failedModel: task.retryNotification.failedModel, failedError: errorInfo.message, nextModel: `${providerID}/${transformedModelId}`, }) const key = task.model ? `${task.model.providerID}/${task.model.modelID}` : task.agent const queue = queuesByKey.get(key) ?? [] const retryInput: LaunchInput = { description: task.description, prompt: task.prompt, agent: task.agent, parentSessionID: task.parentSessionID, parentMessageID: task.parentMessageID, parentModel: task.parentModel, parentAgent: task.parentAgent, parentTools: task.parentTools, model: nextModel, fallbackChain: task.fallbackChain, category: task.category, isUnstableAgent: task.isUnstableAgent, } if (previousSessionID) { await abortWithTimeout(client, previousSessionID).catch(() => {}) } queue.push({ task, input: retryInput, attemptID: nextAttempt.attemptID }) queuesByKey.set(key, queue) processKey(key) return true }