Skip to content

fix(jetbrains): stop mode picker from cancelling running sessions - #13591

Merged
2 commits merged into
mainfrom
fix-jetbrains-mode-switch-session-cancel
Aug 31, 2026
Merged

fix(jetbrains): stop mode picker from cancelling running sessions#13591
2 commits merged into
mainfrom
fix-jetbrains-mode-switch-session-cancel

Conversation

@kirillk

@kirillk kirillk commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Issue

Fixes #

No tracked issue — found while investigating a user report of three sessions in different worktrees dying mid-turn with no visible error, traced to a mode switch in an unrelated session tab.

Context

Picking a mode in the JetBrains chat prompt (SessionController.selectAgent) wrote default_agent to the CLI's global config via PATCH /global/config. The CLI treats any change to that file as a reason to dispose every instance it holds, which cancels every running turn in every open worktree — a UI preference in one session tab was silently killing work in progress everywhere else.

Nothing told the user this happened: the CLI reports both a user-initiated Stop and a server-initiated cancellation as the same MessageAbortedError, and the JetBrains UI treated every abort as a deliberate Stop — a muted "Stopped" line, no reason, no Retry, no telemetry.

VS Code and the TUI never had this bug: both keep the mode pick entirely client-side and send it per-prompt, never writing it to the CLI's global config.

Implementation

The fix: selectAgent no longer calls the CLI at all. The picked mode already travels with every prompt (PromptDto.agent); the only thing missing was persisting it locally for new sessions, which now lives in KiloPluginSettings (PropertiesComponent, IDE-local) as kilo.session.agent. A new session seeds its mode from that remembered pick, falling back to the CLI's default_agent if the remembered mode no longer exists. This mirrors VS Code's agentSelections store and the TUI's in-memory agentStore.

Deleted the now-dead RPC path end to end so it can't be reintroduced: ConfigUpdateDto, KiloSessionRpcApi.updateConfig, KiloSessionRpcApiImpl.updateConfig, KiloSessionService.updateConfig, KiloBackendChatManager.updateConfig, KiloCliDataParser.buildConfigPartial.

Visibility for the case that's still real: the CLI can legitimately cancel a turn on its own (a settings/provider/org change disposing instances). Since the abort itself can't say who caused it, SessionController now tracks a local stopRequested flag, set only on an explicit Stop or on the deliberate abort inside revert/redo, and reset on every new turn/send. An abort that arrives without that flag is promoted to SessionState.Error (with Retry available) instead of the silent "Stopped", and raises a notification and Session Error telemetry.

To let that error name its cause, the backend synthesizes a new ChatEventDto.SessionInterrupted(sessionID, reason) into the same SSE-derived stream when global.disposed / server.instance.disposed arrives while a session is busy (KiloBackendAppService.reportDisposal, was logSessionDisposalRisk — previously only logged a warning). This event races the abort it explains (the CLI publishes the cancellation mid-disposal), so the frontend handles both arrival orders and relabels an already-visible cancellation when the reason turns up late.

The same disposal also badges the affected rows in the session list, Agent Manager worktree list, and Agents-tab dot. That badge is recorded by a direct activity.interrupt(...) call rather than off the event stream: the disposal reloads the app in the same breath, the reload restarts the activity collector, and the chat event flow replays nothing, so an emission racing that restart can be dropped. KiloBackendActivityManager.start() also had to stop clearing state on an in-place restart (new private detach()), or the reload would erase the badge the disposal had just recorded — thanks @kilo-code-bot for catching that the first version of this branch was dead on exactly that path. Note the badge only becomes visible once the cancelled session reports idle, since kind() deliberately ranks live work above a past error so a resumed row keeps spinning.

A reopened session with a historically-aborted tail still shows a plain "Stopped": after a reload there's no way to know who stopped it, and flagging every past Stop as a failure would be worse than the status quo.

Scope note: this PR is JetBrains-only, per explicit instruction. The CLI-side defect is still there — PATCH /global/config disposes every instance with no busy guard, and VS Code's own Settings-save paths (and JetBrains' Settings pages) can still trigger it. The plugin now explains the fallout instead of hiding it, but the guard itself belongs in packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts and is out of scope here.

Screenshots / Video

N/A — no visual layout changed (existing error-card and footer components are reused with new copy); the new balloon uses the existing KiloNotifications.error(...) chrome. Did not launch a sandboxed IDE to capture it; see "Blocked checks" below.

How to Test

Manual/local verification

Ran by the agent, from packages/kilo-jetbrains/:

  • ./gradlew typecheck — clean
  • ./gradlew :backend:test :frontend:test — all green, including new tests:
    • SessionCancellationTest (10 cases: unrequested-abort → error not "Stopped", reason arriving before/after the abort, balloon content, telemetry, Retry availability, revert-abort still counts as requested, flag doesn't leak into the next turn, reopened session keeps a historical abort as "Stopped")
    • ConfigSelectionTest — mode switch no longer calls the CLI, remembered mode seeds a new session ahead of the CLI default, CLI default wins when the remembered mode is gone
    • KiloBackendChatManagerTest.interrupt emits one reason event per running session
    • KiloBackendAppServiceTest — disposal while a session is busy names that session; disposal with nothing busy stays silent; disposal badges the cancelled session after the reload settles (drives real SSE global.disposed through MockCliServer)
    • KiloBackendActivityManagerTest — interrupt badges the session, survives the reload that follows a disposal, is cleared by resumed work, and is cleared by a real stop()
  • bun run script/check-md-table-padding.ts, bun run script/check-workflows.ts, bun run script/extract-source-links.ts from repo root — clean, no diff

Both halves of the badge fix were confirmed load-bearing by reverting each in turn and watching the matching test fail — restoring stop() inside start() breaks interrupt badge survives the reload that follows a disposal, and dropping the activity.interrupt(...) call breaks disposal badges the cancelled session after the reload settles.

Reviewer test steps

  1. Run split-mode or sandboxed IDE (./gradlew runIdeSplitMode from packages/kilo-jetbrains/) with two project windows on two different worktrees, or two chat tabs in Agent Manager pointed at different directories.
  2. Start a task in worktree A, let it run (busy).
  3. In worktree B (or another tab), switch the mode picker (e.g. Code → Plan).
  4. Confirm worktree A's task keeps running uninterrupted — this is the regression.
  5. Separately: trigger an actual CLI disposal while a task is busy (e.g. change a setting that writes default_agent/global config through Settings, or restart the CLI backend) and confirm the busy session's card shows an explanation with a Retry button and a balloon appears, instead of a plain "Stopped" line.
  6. With that same cancelled session, confirm its row in the session list / Agent Manager worktree list carries an error badge once it settles to idle.

Blocked checks and substitute verification

  • Did not launch a sandboxed IDE to visually confirm the balloon/error-card rendering end to end, since that requires a multi-minute IDE boot per iteration; substituted with the SessionCancellationTest suite exercising the exact SessionController state transitions (SessionState.Error construction, notify(...) call, canRetry()) and the KiloBackendAppServiceTest/KiloBackendChatManagerTest suites exercising the real SSE→event wiring against MockCliServer.

Checklist

  • Issue linked above, or exception explained
  • Tests/verification described
  • Screenshots/video included for visual changes, or marked N/A
  • Changeset considered for user-facing changes
  • I personally reviewed the diff and can explain the changes, including any AI-assisted work.

Get in Touch

Switching the chat mode picker wrote default_agent to the CLI's global
config. The CLI disposes every instance it holds whenever that file
changes, which cancels every running turn in every open worktree --
three unrelated sessions died mid-turn from a single mode switch, with
no error shown because a server-initiated cancellation and a user Stop
both report the same MessageAbortedError.

The mode pick now stays client-side: it rides on PromptDto.agent per
turn (as it already did) and is remembered in KiloPluginSettings so new
sessions still open in the last-picked mode, matching how VS Code and
the TUI already handle this. No CLI config write happens.

Since the CLI can still legitimately cancel a turn on its own (a
settings/provider change disposing instances), the plugin now tells
those apart from a user Stop: an unrequested abort shows the reason and
offers Retry instead of a silent "Stopped", raises a notification, and
is captured in telemetry. The backend synthesizes a session.interrupted
event naming the cause when disposal happens while a session is busy.
@kilo-code-bot

kilo-code-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt
Previous Review Summary (commit a68cb8a)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit a68cb8a)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt 95 SessionInterrupted activity badges are cleared by the disposal-triggered load()/activity.start() restart
Files Reviewed (23 files)
  • .changeset/jetbrains-mode-switch-cancels-sessions.md
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt - 1 issue
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt
  • packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendAppServiceTest.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt
  • packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/plugin/KiloPluginSettings.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt
  • packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCancellationTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt
  • packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt

Fix these issues in Kilo Cloud


Reviewed by grok-4.6 · Input: 78.2K · Output: 13.6K · Cached: 530.6K

Review guidance: REVIEW.md from base branch main

The activity badge for a cancelled turn never appeared. reportDisposal
runs immediately before load(), load() calls activity.start(), and
start() fully stopped first -- clearing errors, statuses, and the
directory resolver. The chat event flow also replays nothing, so the
restarted collector could not re-see the SessionInterrupted it had just
missed. The badge branch was dead on the only path that emits the event.

start() now detaches the collectors in place and keeps what they
recorded; a real teardown still clears everything through stop(). The
badge is recorded by a direct activity.interrupt() call ordered with the
disposal that caused it, rather than racing a flow emission against the
reload that swaps collectors, so the event branch is gone.

Both new tests fail without their respective halves of this fix.
@kirillk

kirillk commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the review in 9601737.

The bot's single suggestion was correct and worth the catch: the activity-badge branch I had added was dead on the only code path that emits the event. reportDisposal runs immediately before load(), load() calls activity.start(...), and start() began with a full stop() that cleared errors and also nulled statuses and the directory resolver — and because the chat event flow has no replay, the restarted collector could never re-see the SessionInterrupted it missed.

Two changes, both verified load-bearing by reverting each and watching the matching test fail:

  • KiloBackendActivityManager.start() now detaches collectors in place via a new private detach(), keeping what they recorded. A genuine teardown still clears everything through stop().
  • The badge is recorded by a direct activity.interrupt(ids) call from reportDisposal, ordered with the disposal that caused it, so it cannot land in the window where no collector is subscribed. The SessionInterrupted branch in handle() is gone; the event now serves only the frontend, which subscribes independently and is not torn down by the reload.

Five new tests cover it (four unit in KiloBackendActivityManagerTest, one end-to-end in KiloBackendAppServiceTest driving real SSE global.disposed through MockCliServer). Full ./gradlew typecheck and :backend:test :frontend:test are green.

Also corrected the PR description: it now documents the badge behaviour and the caveat I only noticed while writing the test — the badge appears once the cancelled session reports idle, because kind() deliberately ranks live work above a past error.

@kirillk kirillk closed this pull request by merging all changes into main in ab14325 Aug 31, 2026
@kirillk
kirillk deleted the fix-jetbrains-mode-switch-session-cancel branch August 31, 2026 12:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant