fix(jetbrains): stabilize workspace reload state

This commit is contained in:
kirillk
2026-07-08 11:11:02 -04:00
parent 2b8d19617d
commit 71b2c3a5d8
3 changed files with 46 additions and 38 deletions
@@ -18,7 +18,7 @@ sealed class KiloAppState {
data object Connecting : KiloAppState()
data class Loading(val progress: LoadProgress) : KiloAppState()
data class MigrationRequired(val detection: LegacyMigrationDetection) : KiloAppState()
data class Ready(val data: AppData) : KiloAppState()
data class Ready(val data: AppData, val rev: Long = 0) : KiloAppState()
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloAppState()
}
@@ -57,6 +57,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -118,6 +119,7 @@ class KiloBackendAppService private constructor(
private var loader: Job? = null
private var closed = false
private val loadLock = Any()
private val rev = AtomicLong()
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
val appState: StateFlow<KiloAppState> = _appState.asStateFlow()
@@ -338,7 +340,6 @@ class KiloBackendAppService private constructor(
private fun load() {
synchronized(loadLock) {
loader?.cancel()
eventWatcher?.cancel()
loader = cs.launch {
val start = System.currentTimeMillis()
log.info("Application starting — loading config, profile, notifications")
@@ -655,7 +656,7 @@ class KiloBackendAppService private constructor(
private fun setAppReady(data: AppData) {
warnings = data.warnings
if (data.warnings.isNotEmpty()) warnAppWarnings(data.warnings)
_appState.value = KiloAppState.Ready(data)
_appState.value = KiloAppState.Ready(data, rev.incrementAndGet())
}
private fun setAppError(message: String, errors: List<LoadError>) {
@@ -19,9 +19,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -472,23 +470,25 @@ class KiloBackendWorkspaceTest {
ws.state.first { it is KiloWorkspaceState.Ready }
}
// Fire rapid reloads — simulates rapid SSE disposed events
mock.providers = OPENAI_PROVIDERS_JSON
val before = mock.requestCount("/provider")
repeat(5) { ws.reload() }
// Final state must be valid Ready
withTimeout(15_000) {
while (true) {
val state = ws.state.value
if (state is KiloWorkspaceState.Ready) {
delay(300)
if (ws.state.value is KiloWorkspaceState.Ready) break
}
delay(100)
}
}
assertTrue(
mock.awaitRequestCount("/provider", before + 1),
"Workspace reload did not request providers; state=${ws.state.value}; logs=${log.messages}",
)
val state = withTimeout(15_000) {
ws.state.first {
it is KiloWorkspaceState.Ready &&
it.providers.providers.firstOrNull()?.id == "openai"
}
} as KiloWorkspaceState.Ready
val state = ws.state.value as KiloWorkspaceState.Ready
assertEquals(1, state.providers.providers.size)
assertEquals("openai", state.providers.providers[0].id)
assertEquals(1, state.agents.agents.size)
}
@@ -502,35 +502,29 @@ class KiloBackendWorkspaceTest {
val app = setup()
val initial = ready(app)
// Change providers response then fire disposed event
mock.providers = """{
"all": [{
"id": "openai",
"name": "OpenAI",
"source": "api",
"env": [],
"options": {},
"models": {}
}],
"default": {},
"connected": ["openai"]
}"""
mock.providers = OPENAI_PROVIDERS_JSON
assertTrue(mock.awaitSseConnection())
val prev = (app.appState.value as KiloAppState.Ready).rev
val before = mock.requestCount("/global/config")
val reload = async(start = CoroutineStart.UNDISPATCHED) {
app.appState.drop(1).first { it is KiloAppState.Ready }
app.appState.first { it is KiloAppState.Ready && it.rev > prev }
}
mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")
assertTrue(
mock.awaitRequestCount("/global/config", before + 1),
"global.disposed did not start app reload; state=${app.appState.value}; logs=${log.messages}",
)
withTimeout(15_000) { reload.await() }
// Get a fresh workspace — old one was stopped during reload
val ws = app.workspaces.get("/test/project")
assertTrue(ws !== initial)
withTimeout(15_000) {
ws.state.first { it is KiloWorkspaceState.Ready }
}
val state = ws.state.value as KiloWorkspaceState.Ready
val state = withTimeout(15_000) {
ws.state.first {
it is KiloWorkspaceState.Ready &&
it.providers.providers.firstOrNull()?.id == "openai"
}
} as KiloWorkspaceState.Ready
assertEquals("openai", state.providers.providers[0].id)
}
@@ -572,6 +566,19 @@ class KiloBackendWorkspaceTest {
"connected": ["anthropic"]
}""".trimIndent()
private val OPENAI_PROVIDERS_JSON = """{
"all": [{
"id": "openai",
"name": "OpenAI",
"source": "api",
"env": [],
"options": {},
"models": {}
}],
"default": {},
"connected": ["openai"]
}""".trimIndent()
private val AGENTS_JSON = """[
{"name":"code","displayName":"Code","mode":"primary","permission":[],"options":{}}
]""".trimIndent()