feat: correx-managed model lifecycle slice 3 — manual swap + pin
ManagedInferenceRouter owns a live @Volatile pin (decision D1): swap(modelId) makes a model resident and pins it; clearPin() releases it. Pin is highest precedence in route() (pin > stage.modelId > capability > default). New SwapModel/ClearModelPin client messages handled in GlobalStreamHandler via ServerModule.modelSwapper (null on the static path). ServerMessage.ModelChanged (model.changed) mapped from ModelLoadedEvent/ModelUnloadedEvent — the swap's load event surfaces to clients through streamGlobal, so the handler doesn't push it directly. Tests: pin precedence + swap/clear, Model* -> ModelChanged. Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 3 of 5).
This commit is contained in:
+32
-1
@@ -30,6 +30,13 @@ class ManagedInferenceRouter(
|
||||
private val byId: Map<String, ModelDescriptor> = descriptors.associateBy { it.modelId }
|
||||
private val ordered: List<ModelDescriptor> = descriptors
|
||||
|
||||
/**
|
||||
* Manual-swap pin — global operator intent. Live runtime state (decision D1): not event-sourced,
|
||||
* so a restart reverts to the default model. When set, it overrides per-stage selection.
|
||||
*/
|
||||
@Volatile
|
||||
private var pinnedModelId: String? = null
|
||||
|
||||
override suspend fun route(
|
||||
stageId: StageId,
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
@@ -40,7 +47,8 @@ class ManagedInferenceRouter(
|
||||
requiredCapabilities: Set<ModelCapability>,
|
||||
modelId: String?,
|
||||
): InferenceProvider {
|
||||
val targetId = modelId
|
||||
val targetId = pinnedModelId
|
||||
?: modelId
|
||||
?: capabilityMatch(requiredCapabilities)
|
||||
?: defaultModelId
|
||||
val descriptor = byId[targetId]
|
||||
@@ -48,6 +56,29 @@ class ManagedInferenceRouter(
|
||||
return manager.ensureLoaded(descriptor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual swap from the operator: make [modelId] resident and pin it so it beats per-stage
|
||||
* selection for all later work until [clearPin]. The load emits a `ModelLoadedEvent` (when the
|
||||
* resident model actually changes), which surfaces to clients via the event stream.
|
||||
*
|
||||
* @throws NoEligibleProviderException if [modelId] is not a configured model.
|
||||
*/
|
||||
suspend fun swap(modelId: String): InferenceProvider {
|
||||
val descriptor = byId[modelId]
|
||||
?: throw NoEligibleProviderException(StageId("manual-swap"), emptySet())
|
||||
val provider = manager.ensureLoaded(descriptor)
|
||||
pinnedModelId = modelId
|
||||
return provider
|
||||
}
|
||||
|
||||
/** Clear the manual-swap pin; later stages resume per-stage selection. */
|
||||
fun clearPin() {
|
||||
pinnedModelId = null
|
||||
}
|
||||
|
||||
/** The currently pinned model id, or null when no manual swap is active. */
|
||||
fun pinnedModel(): String? = pinnedModelId
|
||||
|
||||
/**
|
||||
* Selects the configured model whose declared capabilities cover every required capability,
|
||||
* preferring the highest summed score over those required capabilities. Returns null when no
|
||||
|
||||
Reference in New Issue
Block a user