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:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user