214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
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 type FallbackRetryHandlerDeps = {
|
|
log: typeof log
|
|
readProviderModelsCache: typeof readProviderModelsCache
|
|
readConnectedProvidersCache: typeof readConnectedProvidersCache
|
|
shouldRetryError: typeof shouldRetryError
|
|
getNextFallback: typeof getNextFallback
|
|
hasMoreFallbacks: typeof hasMoreFallbacks
|
|
selectFallbackProvider: typeof selectFallbackProvider
|
|
transformModelForProvider: typeof transformModelForProvider
|
|
}
|
|
|
|
const defaultFallbackRetryHandlerDeps: FallbackRetryHandlerDeps = {
|
|
log,
|
|
readProviderModelsCache,
|
|
readConnectedProvidersCache,
|
|
shouldRetryError,
|
|
getNextFallback,
|
|
hasMoreFallbacks,
|
|
selectFallbackProvider,
|
|
transformModelForProvider,
|
|
}
|
|
|
|
export async function tryFallbackRetry(args: {
|
|
task: BackgroundTask
|
|
errorInfo: { name?: string; message?: string }
|
|
source: string
|
|
concurrencyManager: ConcurrencyManager
|
|
client: OpencodeClient
|
|
idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>>
|
|
queuesByKey: Map<string, QueueItem[]>
|
|
processKey: (key: string) => void
|
|
onRetrying?: (details: {
|
|
task: BackgroundTask
|
|
source: string
|
|
previousSessionID?: string
|
|
failedModel?: string
|
|
failedError?: string
|
|
nextModel: string
|
|
}) => void
|
|
deps?: Partial<FallbackRetryHandlerDeps>
|
|
}): Promise<boolean> {
|
|
const { task, errorInfo, source, concurrencyManager, client, idleDeferralTimers, queuesByKey, processKey, onRetrying } = args
|
|
const deps = { ...defaultFallbackRetryHandlerDeps, ...args.deps }
|
|
const fallbackChain = task.fallbackChain
|
|
const canRetry =
|
|
deps.shouldRetryError(errorInfo) &&
|
|
fallbackChain &&
|
|
fallbackChain.length > 0 &&
|
|
deps.hasMoreFallbacks(fallbackChain, task.attemptCount ?? 0)
|
|
|
|
if (!canRetry) return false
|
|
|
|
const attemptCount = task.attemptCount ?? 0
|
|
const providerModelsCache = deps.readProviderModelsCache()
|
|
const connectedProviders = providerModelsCache?.connected ?? deps.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 = deps.getNextFallback(fallbackChain, selectedAttemptCount)
|
|
if (!candidate) break
|
|
selectedAttemptCount++
|
|
if (!isReachable(candidate)) {
|
|
deps.log("[background-agent] Skipping unreachable fallback:", {
|
|
taskId: task.id,
|
|
source,
|
|
model: candidate.model,
|
|
providers: candidate.providers,
|
|
})
|
|
continue
|
|
}
|
|
const candidateProviderID = deps.selectFallbackProvider(
|
|
candidate.providers,
|
|
task.model?.providerID,
|
|
)
|
|
const candidateModelID = deps.transformModelForProvider(candidateProviderID, candidate.model)
|
|
const isNoOpFallback =
|
|
!!task.model &&
|
|
candidateProviderID.toLowerCase() === task.model.providerID.toLowerCase() &&
|
|
canonicalizeModelID(candidateModelID) === canonicalizeModelID(task.model.modelID)
|
|
if (isNoOpFallback) {
|
|
deps.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 ?? deps.selectFallbackProvider(
|
|
nextFallback.providers,
|
|
task.model?.providerID,
|
|
)
|
|
|
|
deps.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 = deps.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,
|
|
teamRunId: task.teamRunId,
|
|
model: nextModel,
|
|
fallbackChain: task.fallbackChain,
|
|
category: task.category,
|
|
isUnstableAgent: task.isUnstableAgent,
|
|
onSessionCreated: task.onSessionCreated,
|
|
}
|
|
|
|
if (previousSessionID) {
|
|
await abortWithTimeout(client, previousSessionID).catch(() => {})
|
|
}
|
|
|
|
queue.push({ task, input: retryInput, attemptID: nextAttempt.attemptId })
|
|
queuesByKey.set(key, queue)
|
|
processKey(key)
|
|
return true
|
|
}
|