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