feat: correx-managed model lifecycle slice 1 — config + manager factory + boot/shutdown

[[models]] config (ModelConfig/ModelsSettings) parsed by ConfigLoader;
InfrastructureModule.createModelManager + modelConfigToDescriptor; Main.kt
managed boot path spawns the default llama-server when [[models]] is present
(static [[providers]] path preserved when absent) and kills it on shutdown.

Plan: docs/plans/2026-05-31-model-lifecycle-management.md (slice 1 of 5).
This commit is contained in:
2026-06-01 11:03:05 +04:00
parent 5beb866036
commit e45a626cc4
10 changed files with 576 additions and 39 deletions
@@ -23,6 +23,8 @@ dependencies {
implementation "io.ktor:ktor-serialization-kotlinx-json:$ktor_version"
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.ktor:ktor-client-mock:$ktor_version"
testImplementation project(':infrastructure:persistence')
testImplementation "org.jetbrains.kotlin:kotlin-test"
}
tasks.named("koverVerify").configure { enabled = false }
@@ -0,0 +1,188 @@
package com.correx.infrastructure.inference.llama.cpp
import com.correx.core.events.EventDispatcher
import com.correx.core.events.events.ModelLoadedEvent
import com.correx.core.events.events.ModelUnloadedEvent
import com.correx.infrastructure.inference.commons.ModelDescriptor
import com.correx.infrastructure.inference.commons.ModelLoadException
import com.correx.infrastructure.inference.commons.ResidencyMode
import com.correx.infrastructure.persistence.InMemoryEventStore
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.headersOf
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/**
* Tests for [DefaultModelManager] lifecycle using a stub process and a mock HTTP health endpoint.
*
* Stub process: uses the system "sleep" binary called with llama-server-style args.
* The process may exit quickly (args are not understood by sleep), but the mock HTTP
* client returns "healthy" before the manager ever checks process liveness, so load()
* succeeds. This approach intentionally avoids any dependency on a real llama-server
* binary.
*/
class DefaultModelManagerTest {
private fun healthyHttpClient(): HttpClient {
val engine = MockEngine {
respond(
content = """{"status":"healthy"}""",
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
return HttpClient(engine)
}
private fun unhealthyHttpClient(): HttpClient {
val engine = MockEngine {
respond(
content = """{"status":"loading"}""",
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
)
}
return HttpClient(engine)
}
private fun descriptor(id: String = "test-model") = ModelDescriptor(
modelId = id,
modelPath = "/dev/null",
residencyMode = ResidencyMode.PERSISTENT,
contextSize = 512,
)
@Test
fun `load spawns process, polls health, emits ModelLoadedEvent, returns provider`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val manager = DefaultModelManager(
llamaServerBin = "sleep",
host = "127.0.0.1",
port = 29999,
healthTimeoutMs = 5_000L,
eventStore = eventStore,
httpClient = healthyHttpClient(),
eventDispatcher = dispatcher,
)
val provider = manager.load(descriptor())
assertNotNull(provider)
assertEquals("test-model", manager.currentModel()?.modelId)
val events = eventStore.allEvents().toList()
val loadedEvent = events.firstOrNull { it.payload is ModelLoadedEvent }
assertNotNull(loadedEvent, "ModelLoadedEvent must have been emitted")
assertEquals("test-model", (loadedEvent!!.payload as ModelLoadedEvent).modelId)
assertEquals("correx:model-manager", loadedEvent.metadata.sessionId.value)
manager.unload("test-model")
}
@Test
fun `currentModel returns null before any load`() {
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val manager = DefaultModelManager(
llamaServerBin = "sleep",
host = "127.0.0.1",
port = 29998,
healthTimeoutMs = 5_000L,
eventStore = eventStore,
httpClient = healthyHttpClient(),
eventDispatcher = dispatcher,
)
assertNull(manager.currentModel())
}
@Test
fun `loading same model twice returns provider without re-spawning`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val manager = DefaultModelManager(
llamaServerBin = "sleep",
host = "127.0.0.1",
port = 29997,
healthTimeoutMs = 5_000L,
eventStore = eventStore,
httpClient = healthyHttpClient(),
eventDispatcher = dispatcher,
)
manager.load(descriptor("model-a"))
manager.load(descriptor("model-a"))
// Only one ModelLoadedEvent because the second load is a no-op
val loadedEvents = eventStore.allEvents().filter { it.payload is ModelLoadedEvent }.toList()
assertEquals(1, loadedEvents.size)
manager.unload("model-a")
}
@Test
fun `unload emits ModelUnloadedEvent and clears currentModel`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val manager = DefaultModelManager(
llamaServerBin = "sleep",
host = "127.0.0.1",
port = 29996,
healthTimeoutMs = 5_000L,
eventStore = eventStore,
httpClient = healthyHttpClient(),
eventDispatcher = dispatcher,
)
manager.load(descriptor("model-b"))
manager.unload("model-b")
assertNull(manager.currentModel())
val unloadedEvents = eventStore.allEvents().filter { it.payload is ModelUnloadedEvent }.toList()
assertEquals(1, unloadedEvents.size)
assertEquals("model-b", (unloadedEvents[0].payload as ModelUnloadedEvent).modelId)
assertEquals("correx:model-manager", unloadedEvents[0].metadata.sessionId.value)
}
@Test
fun `unload without prior load throws ModelLoadException`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val manager = DefaultModelManager(
llamaServerBin = "sleep",
host = "127.0.0.1",
port = 29995,
healthTimeoutMs = 5_000L,
eventStore = eventStore,
httpClient = healthyHttpClient(),
eventDispatcher = dispatcher,
)
assertThrows<ModelLoadException> { runBlocking { manager.unload("nonexistent") } }
}
@Test
fun `load throws ModelLoadException on health timeout`(): Unit = runBlocking {
val eventStore = InMemoryEventStore()
val dispatcher = EventDispatcher(eventStore)
val manager = DefaultModelManager(
llamaServerBin = "sleep",
host = "127.0.0.1",
port = 29994,
healthTimeoutMs = 100L,
eventStore = eventStore,
httpClient = unhealthyHttpClient(),
eventDispatcher = dispatcher,
)
assertThrows<ModelLoadException> { runBlocking { manager.load(descriptor("timeout-model")) } }
}
}