merge: integrate sonnet-vikunja (#299,#300,#297,#301) into codex handoff HEAD
# Conflicts: # core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestrationTuning.kt # examples/workflows/prompts/analyst_freestyle.md
This commit is contained in:
@@ -2,6 +2,7 @@ package com.correx.core.inference
|
||||
|
||||
import com.correx.core.events.types.ProviderId
|
||||
import com.correx.core.events.types.StageId
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.time.Duration
|
||||
@@ -21,6 +22,12 @@ class DefaultInferenceRouter(
|
||||
private val strategy: RoutingStrategy,
|
||||
private val cacheTtl: Duration = 5.seconds,
|
||||
private val timeSource: TimeSource = TimeSource.Monotonic,
|
||||
// A provider that briefly drops (crash + qa-stack restart) shouldn't collapse into a hard
|
||||
// NoEligibleProvider abort — give it bounded time to come back before giving up. This only
|
||||
// applies when the capability IS configured on some provider but that provider is currently
|
||||
// unhealthy; a capability nobody ever declared fails immediately (see routeCapabilityCandidates).
|
||||
private val unavailableRetryAttempts: Int = 3,
|
||||
private val unavailableRetryDelay: Duration = 2.seconds,
|
||||
) : InferenceRouter {
|
||||
|
||||
private val cache = mutableMapOf<ProviderId, HealthEntry>()
|
||||
@@ -45,12 +52,49 @@ class DefaultInferenceRouter(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshedHealth(provider: InferenceProvider): ProviderHealth =
|
||||
lockFor(provider.id).withLock {
|
||||
val fresh = provider.healthCheck()
|
||||
mapMutex.withLock { cache[provider.id] = HealthEntry(fresh, timeSource.markNow()) }
|
||||
fresh
|
||||
}
|
||||
|
||||
// Bypasses healthCheck()/TTL entirely — writes Unavailable straight into the cache so the very
|
||||
// next route() call gates on it, closing the ~18s reactive-poll lag (#300). A later cache-TTL
|
||||
// expiry or the bounded-wait re-check in route() will naturally pick the provider back up once
|
||||
// its own healthCheck() reports healthy again.
|
||||
override suspend fun reportFailure(providerId: ProviderId, reason: String) {
|
||||
lockFor(providerId).withLock {
|
||||
mapMutex.withLock { cache[providerId] = HealthEntry(ProviderHealth.Unavailable(reason), timeSource.markNow()) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun route(stageId: StageId, requiredCapabilities: Set<ModelCapability>): InferenceProvider {
|
||||
val candidates = requiredCapabilities
|
||||
.flatMap { registry.resolve(it) }
|
||||
.distinctBy { it.id }
|
||||
.ifEmpty { registry.listAll() }
|
||||
val healthy = candidates.filter { cachedHealth(it) !is ProviderHealth.Unavailable }
|
||||
|
||||
// Nobody was ever configured with this capability set — no amount of waiting fixes that,
|
||||
// fail fast instead of burning the bounded-wait budget below.
|
||||
if (requiredCapabilities.isNotEmpty() &&
|
||||
candidates.none { it.capabilities().map { c -> c.capability }.toSet().containsAll(requiredCapabilities) }
|
||||
) {
|
||||
throw NoEligibleProviderException(stageId, requiredCapabilities)
|
||||
}
|
||||
|
||||
var healthy = candidates.filter { cachedHealth(it) !is ProviderHealth.Unavailable }
|
||||
var attempt = 0
|
||||
while (healthy.isEmpty() && attempt < unavailableRetryAttempts) {
|
||||
attempt++
|
||||
log.warn(
|
||||
"route: capability {} configured but all candidates unhealthy for stage={}" +
|
||||
" — waiting {} (attempt {}/{}) before declaring NoEligibleProvider",
|
||||
requiredCapabilities, stageId.value, unavailableRetryDelay, attempt, unavailableRetryAttempts,
|
||||
)
|
||||
delay(unavailableRetryDelay)
|
||||
healthy = candidates.filter { refreshedHealth(it) !is ProviderHealth.Unavailable }
|
||||
}
|
||||
val selected = strategy.select(healthy, requiredCapabilities)
|
||||
// Post-selection re-check closes the TOCTOU window between initial filter and dispatch.
|
||||
when (val postHealth = selected.healthCheck()) {
|
||||
|
||||
@@ -39,6 +39,14 @@ interface InferenceRouter {
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
modelId: String?,
|
||||
): InferenceProvider = route(stageId, requiredCapabilities)
|
||||
|
||||
/**
|
||||
* Event-driven health gate: called the moment a connection-level failure is observed on
|
||||
* [providerId] (e.g. mid-request connection drop), so the NEXT route() call sees it as
|
||||
* unavailable immediately instead of waiting for the next periodic health poll/cache TTL to
|
||||
* catch up. Default no-op for routers that don't cache health.
|
||||
*/
|
||||
suspend fun reportFailure(providerId: com.correx.core.events.types.ProviderId, reason: String) = Unit
|
||||
}
|
||||
|
||||
class NoEligibleProviderException(
|
||||
|
||||
Reference in New Issue
Block a user