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:
2026-06-01 11:48:24 +04:00
parent 382194b498
commit 7341ab578e
9 changed files with 186 additions and 2 deletions
@@ -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