When runtime fallback switches to a different model, the agent's configured variant and reasoningEffort were lost because buildRetryModelPayload only extracted variant from the fallback model string itself. Now buildRetryModelPayload accepts optional agentSettings and uses the agent's variant as fallback when the model string doesn't include one. reasoningEffort is also passed through. 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
import type { HookDeps, RuntimeFallbackTimeout } from "./types"
|
|
import { HOOK_NAME } from "./constants"
|
|
import { log } from "../../shared/logger"
|
|
import { normalizeAgentName, resolveAgentForSession } from "./agent-resolver"
|
|
import { getSessionAgent } from "../../features/claude-code-session-state"
|
|
import { getFallbackModelsForSession } from "./fallback-models"
|
|
import { prepareFallback } from "./fallback-state"
|
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
|
import { buildRetryModelPayload } from "./retry-model-payload"
|
|
import { getLastUserRetryParts } from "./last-user-retry-parts"
|
|
import { extractSessionMessages } from "./session-messages"
|
|
|
|
const SESSION_TTL_MS = 30 * 60 * 1000
|
|
|
|
declare function setTimeout(callback: () => void | Promise<void>, delay?: number): RuntimeFallbackTimeout
|
|
declare function clearTimeout(timeout: RuntimeFallbackTimeout): void
|
|
|
|
export function createAutoRetryHelpers(deps: HookDeps) {
|
|
const {
|
|
ctx,
|
|
config,
|
|
options,
|
|
sessionStates,
|
|
sessionLastAccess,
|
|
sessionRetryInFlight,
|
|
sessionAwaitingFallbackResult,
|
|
sessionFallbackTimeouts,
|
|
pluginConfig,
|
|
sessionStatusRetryKeys,
|
|
} = deps
|
|
|
|
const abortSessionRequest = async (sessionID: string, source: string): Promise<void> => {
|
|
try {
|
|
await ctx.client.session.abort({ path: { id: sessionID } })
|
|
log(`[${HOOK_NAME}] Aborted in-flight session request (${source})`, { sessionID })
|
|
} catch (error) {
|
|
log(`[${HOOK_NAME}] Failed to abort in-flight session request (${source})`, {
|
|
sessionID,
|
|
error: String(error),
|
|
})
|
|
}
|
|
}
|
|
|
|
const clearSessionFallbackTimeout = (sessionID: string) => {
|
|
const timer = sessionFallbackTimeouts.get(sessionID)
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
sessionFallbackTimeouts.delete(sessionID)
|
|
}
|
|
}
|
|
|
|
const scheduleSessionFallbackTimeout = (sessionID: string, resolvedAgent?: string) => {
|
|
clearSessionFallbackTimeout(sessionID)
|
|
|
|
const timeoutMs = options?.session_timeout_ms ?? config.timeout_seconds * 1000
|
|
if (timeoutMs <= 0) return
|
|
|
|
const timer = setTimeout(async () => {
|
|
sessionFallbackTimeouts.delete(sessionID)
|
|
|
|
const state = sessionStates.get(sessionID)
|
|
if (!state) return
|
|
|
|
if (sessionRetryInFlight.has(sessionID)) {
|
|
log(`[${HOOK_NAME}] Overriding in-flight retry due to session timeout`, { sessionID })
|
|
}
|
|
|
|
await abortSessionRequest(sessionID, "session.timeout")
|
|
sessionRetryInFlight.delete(sessionID)
|
|
|
|
if (state.pendingFallbackModel) {
|
|
state.pendingFallbackModel = undefined
|
|
}
|
|
|
|
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
|
|
if (fallbackModels.length === 0) return
|
|
|
|
log(`[${HOOK_NAME}] Session fallback timeout reached`, {
|
|
sessionID,
|
|
timeoutSeconds: config.timeout_seconds,
|
|
currentModel: state.currentModel,
|
|
})
|
|
|
|
const result = prepareFallback(sessionID, state, fallbackModels, config)
|
|
if (result.success && result.newModel) {
|
|
await autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.timeout")
|
|
}
|
|
}, timeoutMs)
|
|
|
|
sessionFallbackTimeouts.set(sessionID, timer)
|
|
}
|
|
|
|
const autoRetryWithFallback = async (
|
|
sessionID: string,
|
|
newModel: string,
|
|
resolvedAgent: string | undefined,
|
|
source: string,
|
|
): Promise<void> => {
|
|
if (sessionRetryInFlight.has(sessionID)) {
|
|
log(`[${HOOK_NAME}] Retry already in flight, skipping (${source})`, { sessionID })
|
|
return
|
|
}
|
|
|
|
const agentSettings = resolvedAgent
|
|
? pluginConfig?.agents?.[resolvedAgent as keyof typeof pluginConfig.agents]
|
|
: undefined
|
|
const retryModelPayload = buildRetryModelPayload(newModel, agentSettings ? {
|
|
variant: agentSettings.variant,
|
|
reasoningEffort: agentSettings.reasoningEffort,
|
|
} : undefined)
|
|
if (!retryModelPayload) {
|
|
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
|
|
const state = sessionStates.get(sessionID)
|
|
if (state?.pendingFallbackModel) {
|
|
state.pendingFallbackModel = undefined
|
|
}
|
|
return
|
|
}
|
|
|
|
sessionRetryInFlight.add(sessionID)
|
|
let retryDispatched = false
|
|
try {
|
|
const messagesResp = await ctx.client.session.messages({
|
|
path: { id: sessionID },
|
|
query: { directory: ctx.directory },
|
|
})
|
|
const retryParts = getLastUserRetryParts(messagesResp)
|
|
if (retryParts.length > 0) {
|
|
log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, {
|
|
sessionID,
|
|
model: newModel,
|
|
})
|
|
|
|
const retryAgent = resolvedAgent ?? getSessionAgent(sessionID)
|
|
sessionAwaitingFallbackResult.add(sessionID)
|
|
scheduleSessionFallbackTimeout(sessionID, retryAgent)
|
|
|
|
await ctx.client.session.promptAsync({
|
|
path: { id: sessionID },
|
|
body: {
|
|
...(retryAgent ? { agent: retryAgent } : {}),
|
|
...retryModelPayload,
|
|
parts: retryParts,
|
|
},
|
|
query: { directory: ctx.directory },
|
|
})
|
|
retryDispatched = true
|
|
} else {
|
|
log(`[${HOOK_NAME}] No user message found for auto-retry (${source})`, { sessionID })
|
|
}
|
|
} catch (retryError) {
|
|
log(`[${HOOK_NAME}] Auto-retry failed (${source})`, { sessionID, error: String(retryError) })
|
|
} finally {
|
|
sessionRetryInFlight.delete(sessionID)
|
|
if (!retryDispatched) {
|
|
sessionAwaitingFallbackResult.delete(sessionID)
|
|
clearSessionFallbackTimeout(sessionID)
|
|
const state = sessionStates.get(sessionID)
|
|
if (state?.pendingFallbackModel) {
|
|
state.pendingFallbackModel = undefined
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const resolveAgentForSessionFromContext = async (
|
|
sessionID: string,
|
|
eventAgent?: string,
|
|
): Promise<string | undefined> => {
|
|
const resolved = resolveAgentForSession(sessionID, eventAgent)
|
|
if (resolved) return resolved
|
|
|
|
try {
|
|
const messagesResp = await ctx.client.session.messages({
|
|
path: { id: sessionID },
|
|
query: { directory: ctx.directory },
|
|
})
|
|
const msgs = extractSessionMessages(messagesResp)
|
|
if (!msgs || msgs.length === 0) return undefined
|
|
|
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
const info = msgs[i]?.info
|
|
const infoAgent = typeof info?.agent === "string" ? info.agent : undefined
|
|
const normalized = normalizeAgentName(infoAgent)
|
|
if (normalized) {
|
|
return normalized
|
|
}
|
|
}
|
|
} catch {
|
|
return undefined
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
const cleanupStaleSessions = () => {
|
|
const now = Date.now()
|
|
let cleanedCount = 0
|
|
for (const [sessionID, lastAccess] of sessionLastAccess.entries()) {
|
|
if (now - lastAccess > SESSION_TTL_MS) {
|
|
sessionStates.delete(sessionID)
|
|
sessionLastAccess.delete(sessionID)
|
|
sessionRetryInFlight.delete(sessionID)
|
|
sessionAwaitingFallbackResult.delete(sessionID)
|
|
clearSessionFallbackTimeout(sessionID)
|
|
SessionCategoryRegistry.remove(sessionID)
|
|
sessionStatusRetryKeys.delete(sessionID)
|
|
cleanedCount++
|
|
}
|
|
}
|
|
if (cleanedCount > 0) {
|
|
log(`[${HOOK_NAME}] Cleaned up ${cleanedCount} stale session states`)
|
|
}
|
|
}
|
|
|
|
return {
|
|
abortSessionRequest,
|
|
clearSessionFallbackTimeout,
|
|
scheduleSessionFallbackTimeout,
|
|
autoRetryWithFallback,
|
|
resolveAgentForSessionFromContext,
|
|
cleanupStaleSessions,
|
|
}
|
|
}
|
|
|
|
export type AutoRetryHelpers = ReturnType<typeof createAutoRetryHelpers>
|