Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/jetbrains-mode-switch-cancels-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@kilocode/kilo-jetbrains": patch
---

Fix switching chat mode cancelling running tasks in every open worktree, and explain any task Kilo stops on its own

Picking a mode in the chat prompt used to be saved as the CLI's global default, which made the CLI reload and cancel every task that was running anywhere. The mode now stays in the IDE and travels with each message, and it is still remembered for new chats.

When Kilo does stop a task without being asked — a settings or provider change, for example — the chat now shows why, offers Retry, and raises a notification, instead of quietly reporting "Stopped".
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class KiloBackendActivityManager(
directory: (String) -> String?,
chatEvents: SharedFlow<ChatEventDto>,
) {
if (status?.isActive == true || events?.isActive == true) stop()
if (status?.isActive == true || events?.isActive == true) detach()
this.statuses = statuses
this.directory = directory
status = cs.launch {
Expand All @@ -64,10 +64,7 @@ class KiloBackendActivityManager(
}

fun stop() {
status?.cancel()
events?.cancel()
status = null
events = null
detach()
statuses = null
directory = { null }
synchronized(lock) {
Expand All @@ -79,6 +76,37 @@ class KiloBackendActivityManager(
log.info("Activity manager stopped")
}

/**
* Cancels the collectors without discarding what they recorded.
*
* [start] runs on every reload, including the one a disposal triggers in the same breath as
* cancelling the running turns. Clearing state there would erase the interruption badges that
* disposal just recorded, so an in-place restart keeps them and lets fresh collectors carry on.
* A real teardown still goes through [stop].
*/
private fun detach() {
status?.cancel()
events?.cancel()
status = null
events = null
}

/**
* Badge [ids] as having lost a turn nobody asked to stop.
*
* Called directly instead of being driven from [ChatEventDto.SessionInterrupted]: the disposal that
* cancels those turns reloads the app immediately, the reload restarts the event collector, and the
* chat event flow replays nothing — an emission racing that restart can land in the gap where
* nothing is subscribed and be dropped. A direct call is ordered with the disposal that caused it.
*/
fun interrupt(ids: Collection<String>) {
if (ids.isEmpty()) return
synchronized(lock) {
errors.addAll(ids)
recompute()
}
}

private fun handle(event: ChatEventDto) {
when (event) {
is ChatEventDto.PermissionAsked -> permissions.getOrPut(event.sessionID) { mutableSetOf() }.add(event.request.id)
Expand All @@ -89,6 +117,8 @@ class KiloBackendActivityManager(
// A Stop publishes MessageAbortedError. That is a deliberate user action, not a failure, so
// it must not badge the session list, worktree rows, or the Agents tab attention dot.
is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) }
// A cancellation nobody asked for is a failure, but the abort reporting it is
// indistinguishable from a Stop, so that badge arrives through [interrupt] instead.
is ChatEventDto.TurnOpen -> errors.remove(event.sessionID)
// Not every failure publishes a session error — a turn whose provider ended the response in
// error writes the failure onto the message and only reports it through this close reason. The
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.jetbrains.api.model.ProviderOauthAuthorizeRequest
import ai.kilocode.jetbrains.api.model.ProviderOauthCallbackRequest
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.DeviceAuthDto
import ai.kilocode.rpc.dto.ConfigPatchDto
Expand Down Expand Up @@ -826,13 +827,13 @@ class KiloBackendAppService private constructor(
}
}
"global.disposed" -> {
logSessionDisposalRisk("global.disposed")
reportDisposal("global.disposed")
log.info("SSE global.disposed — triggering full application reload")
val current = _appState.value
if (current is KiloAppState.Ready) load()
}
"server.instance.disposed" -> {
logSessionDisposalRisk("server.instance.disposed")
reportDisposal("server.instance.disposed")
log.info("SSE server.instance.disposed — triggering full application reload")
val current = _appState.value
if (current is KiloAppState.Ready) load()
Expand All @@ -843,10 +844,22 @@ class KiloBackendAppService private constructor(
}
}

private fun logSessionDisposalRisk(event: String) {
/**
* Warn, and tell every running session that the CLI is about to cancel it.
*
* Disposing an instance cancels every runner it owns, and the CLI reports that as the same
* `MessageAbortedError` a user Stop produces. Naming the cause here is the only way the UI can
* tell the difference and explain itself instead of quietly reporting "Stopped".
*/
private fun reportDisposal(event: String) {
val active = sessions.statuses.value.filterValues { it.type != "idle" }
if (active.isEmpty()) return
log.warn("SSE $event while sessions are active; sessions may be cancelled count=${active.size} statuses=${active.values.map { it.type }.distinct()}")
// Badged here rather than off the event below, because the reload that follows this restarts the
// activity collector and the event could be dropped in the gap. Both still matter: the badge
// marks the worktree row, the event lets the open session name its own reason.
activity.interrupt(active.keys)
chat.interrupt(active.keys, ChatEventDto.SessionInterrupted.RELOAD)
}

private suspend fun clear() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import ai.kilocode.backend.cli.KiloCliDataParser
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
Expand Down Expand Up @@ -327,25 +326,22 @@ class KiloBackendChatManager(
}
}

// ------ config update ------

fun updateConfig(dir: String, update: ConfigUpdateDto) {
val http = requireClient()
val url = requireBase()

val partial = KiloCliDataParser.buildConfigPartial(update)

val request = Request.Builder()
.url("$url/global/config")
.patch(partial.toRequestBody(JSON_TYPE))
.build()

http.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
val msg = response.body?.string() ?: "unknown error"
log.warn("config update failed: HTTP ${response.code} — $msg")
} else {
log.info("Config updated: model=${update.model}, agent=${update.agent}, temp=${update.temperature}")
// ------ interruption ------

/**
* Tell every session in [ids] that the CLI stopped its turn for [reason].
*
* Synthesized into the same stream the CLI events use so a session's own controller sees it in
* order with the abort it explains. The CLI cannot express this itself: it reports a server-side
* cancellation as the same `MessageAbortedError` a user Stop produces, so the UI would otherwise
* report work nobody stopped as "Stopped".
*/
fun interrupt(ids: Collection<String>, reason: String) {
if (ids.isEmpty()) return
cs.launch {
for (id in ids) {
log.warn("${ChatLogSummary.sid(id)} kind=interrupt route=chat-events reason=$reason")
_events.emit(ChatEventDto.SessionInterrupted(id, reason))
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import ai.kilocode.rpc.dto.CommandDto
import ai.kilocode.rpc.dto.CommandFileDto
import ai.kilocode.rpc.dto.ConfigDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.CompactionConfigDto
import ai.kilocode.rpc.dto.CustomModelDto
import ai.kilocode.rpc.dto.CustomProviderConfigDto
Expand Down Expand Up @@ -915,31 +914,6 @@ object KiloCliDataParser {
return "{${fields.joinToString(",")}}"
}

/**
* Build the partial JSON body for `PATCH /global/config`.
*/
fun buildConfigPartial(update: ConfigUpdateDto): String {
val sb = StringBuilder("{")
var first = true
fun sep() { if (!first) sb.append(","); first = false }

val model = update.model
if (model != null) {
sep(); sb.append(""""model":${escape(model)}""")
}
val agent = update.agent
if (agent != null) {
sep(); sb.append(""""default_agent":${escape(agent)}""")
}
val temp = update.temperature
if (temp != null) {
val target = agent ?: "ask"
sep(); sb.append(""""agent":{"$target":{"temperature":$temp}}""")
}
sb.append("}")
return sb.toString()
}

fun buildConfigPatch(patch: ConfigPatchDto): String {
val allowed = setOf("model", "small_model", "subagent_model", "subagent_variant", "default_agent")
val obj = buildJsonObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
Expand Down Expand Up @@ -286,9 +285,6 @@ class KiloSessionRpcApiImpl internal constructor(
log.warn("${ChatLogSummary.sid(id)} kind=subscription route=rpc-events stop=true failed message=${cause.message}", cause)
}

override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) =
ready { chat.updateConfig(directory, config) }

// ------ permission / question resolution ------

override suspend fun replyPermission(requestId: String, directory: String, reply: PermissionReplyDto) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,64 @@ class KiloBackendActivityManagerTest {
assertEquals("/repo/new", snap["ses_new"]?.directory)
}

@Test
fun `interrupt badges the session as failed`() = runBlocking<Unit> {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()

manager.interrupt(listOf("ses_1"))
statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))

await("ses_1", SessionActivityKindDto.ERROR)
}

/**
* The disposal that cancels a turn reloads the app in the same breath, and that reload calls
* [KiloBackendActivityManager.start] again. Clearing on that in-place restart erased the badge the
* disposal had just recorded, leaving a lost turn resting as if it had finished cleanly.
*/
@Test
fun `interrupt badge survives the reload that follows a disposal`() = runBlocking<Unit> {
directories["ses_1"] = "/repo/wt"
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
start()

manager.interrupt(listOf("ses_1"))

// What load() does after a disposal: same flows, fresh collectors.
val reloaded = MutableStateFlow(mapOf("ses_1" to SessionStatusDto("idle")))
manager.start(reloaded, { directories[it] }, events)

await("ses_1", SessionActivityKindDto.ERROR)
}

@Test
fun `resumed work clears an interrupt badge`() = runBlocking<Unit> {
directories["ses_1"] = "/repo/wt"
start()
manager.interrupt(listOf("ses_1"))
await("ses_1", SessionActivityKindDto.ERROR)

events.emit(ChatEventDto.TurnOpen("ses_1"))
statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))

await("ses_1", SessionActivityKindDto.RUNNING)
}

/** A real teardown is a disconnect, not a restart, so nothing may outlive it. */
@Test
fun `stop clears an interrupt badge`() = runBlocking<Unit> {
directories["ses_1"] = "/repo/wt"
start()
manager.interrupt(listOf("ses_1"))
await("ses_1", SessionActivityKindDto.ERROR)

manager.stop()

assertEquals(emptyMap(), manager.activity.value)
}

private suspend fun await(id: String, kind: SessionActivityKindDto) = withTimeout(5_000) {
manager.activity.first { it[id]?.kind == kind }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import ai.kilocode.rpc.dto.AgentConfigPatchDto
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CompactionPatchDto
import ai.kilocode.rpc.dto.ConfigPatchDto
import ai.kilocode.rpc.dto.SessionActivityKindDto
import ai.kilocode.rpc.dto.WatcherPatchDto
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
Expand Down Expand Up @@ -836,6 +840,77 @@ class KiloBackendAppServiceTest {
assertNotNull(svc.config)
}

/**
* Disposing an instance cancels every runner it owns, and the CLI reports that as the same
* `MessageAbortedError` a user Stop produces. Naming the cause here is the only thing that lets the
* UI explain the lost turn instead of reporting it as "Stopped" — three sessions once died to a
* config reload with no trace the user could see.
*/
@Test
fun `disposal while a session is busy names the reason for that session`() = runBlocking {
val svc = create()
svc.connect()
ready(svc)
mock.awaitSseConnection()

mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""")
withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } }

val received = scope.async(start = CoroutineStart.UNDISPATCHED) {
svc.chat.events.first { it is ChatEventDto.SessionInterrupted }
}
mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")

val event = assertIs<ChatEventDto.SessionInterrupted>(withTimeout(15_000) { received.await() })
assertEquals("ses_abc", event.sessionID)
assertEquals(ChatEventDto.SessionInterrupted.RELOAD, event.reason)
}

/**
* The reload a disposal triggers restarts the activity collector, so the badge has to be recorded in
* a way that survives it — otherwise a worktree row rests as if its cancelled turn had finished.
*/
@Test
fun `disposal badges the cancelled session after the reload settles`() = runBlocking {
// A badge needs a resolvable directory, which a status event alone does not carry.
mock.sessions = """[{"id":"ses_abc","slug":"abc","projectID":"prj_test","directory":"/test/project","title":"Work","version":"1.0.0","time":{"created":1000,"updated":1000}}]"""
val svc = create()
svc.connect()
ready(svc)
mock.awaitSseConnection()
svc.sessions.list("/test/project")

mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"busy","message":"Running..."}}""")
withTimeout(5_000) { svc.sessions.statuses.first { it["ses_abc"]?.type == "busy" } }

mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")
// The cancelled turn then reports idle, which is what lets the badge show: live work
// deliberately outranks a past error so a resumed row keeps spinning instead.
mock.pushEvent("session.status", """{"sessionID":"ses_abc","status":{"type":"idle"}}""")

val badged = withTimeoutOrNull(20_000) {
svc.activity.activity.first { it["ses_abc"]?.kind == SessionActivityKindDto.ERROR }
}
assertNotNull(badged, "Disposal must leave a badge on the session it cancelled; logs=${log.messages}")
assertEquals("/test/project", badged["ses_abc"]?.directory)
}

@Test
fun `disposal with no busy session names nothing`() = runBlocking {
val svc = create()
svc.connect()
ready(svc)
mock.awaitSseConnection()

val received = scope.async(start = CoroutineStart.UNDISPATCHED) {
svc.chat.events.first { it is ChatEventDto.SessionInterrupted }
}
mock.pushEvent("global.disposed", """{"type":"global.disposed"}""")

assertNull(withTimeoutOrNull(2_000) { received.await() })
received.cancel()
}

@Test
fun `restart lifecycle transitions correctly`() = runBlocking {
val svc = create()
Expand Down
Loading
Loading