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
@@ -81,6 +81,8 @@ fun main() {
val firstProvider: InferenceProvider
val infraRegistry: DefaultProviderRegistry
val inferenceRouter: InferenceRouter
// Non-null only on the managed path; backs manual model swap/pin from clients.
var modelSwapper: ManagedInferenceRouter? = null
if (correxConfig.models.isNotEmpty()) {
val settings = correxConfig.modelsSettings
@@ -103,7 +105,9 @@ fun main() {
}.forEach { infraRegistry.register(it) }
// Managed router owns per-stage model selection: it ensures the resolved model is resident
// before routing (stage.modelId > capability match > default), evicting the prior model.
inferenceRouter = ManagedInferenceRouter(modelManager, descriptors, targetModelConfig.id)
val managedRouter = ManagedInferenceRouter(modelManager, descriptors, targetModelConfig.id)
inferenceRouter = managedRouter
modelSwapper = managedRouter
// Shutdown hook to kill the managed llama-server
Runtime.getRuntime().addShutdownHook(Thread {
log.info("Shutdown: unloading managed model '{}'", targetModelConfig.id)
@@ -238,6 +242,7 @@ fun main() {
approvalRepository = repositories.approvalRepository,
toolRegistry = toolRegistry,
sessionUndoService = sessionUndoService,
modelSwapper = modelSwapper,
)
module.start()
log.info("==============================")
@@ -49,6 +49,9 @@ class ServerModule(
val approvalRepository: DefaultApprovalRepository,
val toolRegistry: ToolRegistry,
val sessionUndoService: com.correx.apps.server.undo.SessionUndoService,
// The managed-model router when correx owns the local model process ([[models]] configured);
// null on the static-provider path. Backs the manual SwapModel / ClearModelPin operations.
val modelSwapper: com.correx.infrastructure.inference.commons.ManagedInferenceRouter? = null,
// Long-lived scope owned by the module — backs the event-store subscription.
// SupervisorJob so one failure doesn't kill the whole module;
// Dispatchers.Default since the work is non-blocking and CPU-light.
@@ -14,6 +14,8 @@ import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.WorkflowStartedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.OrchestrationResumedEvent
import com.correx.core.events.events.StageCompletedEvent
@@ -190,6 +192,16 @@ suspend fun domainEventToServerMessage(
sequence = seq,
sessionSequence = sessionSequence,
)
is ModelLoadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = true,
)
is ModelUnloadedEvent -> ServerMessage.ModelChanged(
modelId = p.modelId,
providerId = p.providerId.value,
loaded = false,
)
else -> {
log.debug(
"DomainEventMapper: unmapped payload type={} sessionId={} sequence={}",
@@ -62,4 +62,12 @@ sealed class ClientMessage {
@Serializable
data class ChatInput(val sessionId: SessionId, val text: String, val mode: ChatMode) : ClientMessage()
/** Operator request to make [modelId] resident and pin it (beats per-stage selection). */
@Serializable
data class SwapModel(val modelId: String) : ClientMessage()
/** Operator request to clear the manual-swap pin; per-stage selection resumes. */
@Serializable
data object ClearModelPin : ClientMessage()
}
@@ -268,6 +268,21 @@ sealed interface ServerMessage {
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
/**
* The resident local model changed (load or unload). Derived from `ModelLoadedEvent` /
* `ModelUnloadedEvent`; a global control message (no session cursors). `loaded = false`
* signals the model was unloaded.
*/
@Serializable
@SerialName("model.changed")
data class ModelChanged(
val modelId: String,
val providerId: String,
val loaded: Boolean,
override val sequence: Long? = null,
override val sessionSequence: Long? = null,
) : ServerMessage, NonEventMessage
@Serializable
@SerialName("protocol_error")
data class ProtocolError(
@@ -161,9 +161,38 @@ class GlobalStreamHandler(private val module: ServerModule) {
sendFrame(ServerMessage.ProtocolError("Router error: ${it.message}"))
}
}
is ClientMessage.SwapModel -> handleSwapModel(msg, sendFrame)
is ClientMessage.ClearModelPin -> {
val swapper = module.modelSwapper
if (swapper == null) {
sendFrame(errorResponse("Model management is not enabled"))
} else {
swapper.clearPin()
log.info("model pin cleared")
}
}
}
}
private suspend fun handleSwapModel(
msg: ClientMessage.SwapModel,
sendFrame: suspend (ServerMessage) -> Unit,
) {
val swapper = module.modelSwapper
if (swapper == null) {
sendFrame(errorResponse("Model management is not enabled"))
return
}
// swap() makes the model resident; the resulting ModelLoadedEvent surfaces the change to
// clients via streamGlobal, so we don't push ModelChanged here.
runCatching { swapper.swap(msg.modelId) }
.onSuccess { log.info("swapped to model={}", msg.modelId) }
.onFailure {
log.error("model swap failed for '{}': {}", msg.modelId, it.message)
sendFrame(errorResponse("Model swap failed: ${it.message}"))
}
}
private suspend fun handleApprovalResponse(
msg: ClientMessage.ApprovalResponse,
sendFrame: suspend (ServerMessage) -> Unit,
@@ -490,6 +490,39 @@ class DomainEventMapperTest {
assertNull(result.reason)
}
@Test
fun `ModelLoadedEvent maps to ModelChanged loaded=true`(): Unit = runTest {
val event = storedEvent(
com.correx.core.events.events.ModelLoadedEvent(
modelId = "qwen-coder",
providerId = ProviderId("llama-cpp:qwen-coder"),
sessionId = sessionId,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals(
ServerMessage.ModelChanged("qwen-coder", "llama-cpp:qwen-coder", loaded = true),
result,
)
}
@Test
fun `ModelUnloadedEvent maps to ModelChanged loaded=false`(): Unit = runTest {
val event = storedEvent(
com.correx.core.events.events.ModelUnloadedEvent(
modelId = "qwen-coder",
providerId = ProviderId("llama-cpp"),
sessionId = sessionId,
cancellationReason = null,
),
)
val result = domainEventToServerMessage(event, noopStore, sessionSequence = 0L)
assertEquals(
ServerMessage.ModelChanged("qwen-coder", "llama-cpp", loaded = false),
result,
)
}
@Test
fun `unmapped event returns null`(): Unit = runTest {
val event = storedEvent(SteeringNoteAddedEvent(sessionId = sessionId, content = "test"))