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:
+48
@@ -14,6 +14,7 @@ import com.correx.infrastructure.inference.commons.ResidencyMode
|
||||
import com.correx.testing.fixtures.inference.MockInferenceProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
@@ -124,6 +125,53 @@ class ManagedInferenceRouterTest {
|
||||
assertEquals("managed:default-model", provider.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swap pins a model that then beats per-stage selection`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model"), desc("coder", ModelCapability.Coding to 1.0)),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
router.swap("coder")
|
||||
assertEquals("coder", router.pinnedModel())
|
||||
|
||||
// A stage explicitly asking for a different model is overridden by the pin.
|
||||
val provider = router.route(stage, emptySet(), modelId = "default-model")
|
||||
assertEquals("managed:coder", provider.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearPin restores per-stage selection`(): Unit = runBlocking {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model"), desc("coder", ModelCapability.Coding to 1.0)),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
router.swap("coder")
|
||||
router.clearPin()
|
||||
assertNull(router.pinnedModel())
|
||||
|
||||
val provider = router.route(stage, emptySet(), modelId = "default-model")
|
||||
assertEquals("managed:default-model", provider.id.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swap to an unknown model throws and leaves the pin unset`() {
|
||||
val manager = FakeModelManager()
|
||||
val router = ManagedInferenceRouter(
|
||||
manager = manager,
|
||||
descriptors = listOf(desc("default-model")),
|
||||
defaultModelId = "default-model",
|
||||
)
|
||||
|
||||
assertThrows<NoEligibleProviderException> { runBlocking { router.swap("ghost") } }
|
||||
assertNull(router.pinnedModel())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown explicit modelId throws NoEligibleProviderException`() {
|
||||
val manager = FakeModelManager()
|
||||
|
||||
Reference in New Issue
Block a user