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
@@ -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()