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
5 changes: 5 additions & 0 deletions .changeset/fix-stale-agent-session-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Prevent completed sessions from staying stuck in the working state.
147 changes: 103 additions & 44 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private readonly refreshes = new Map<string, number>()
private readonly anacondaDesktop = new AnacondaDesktopBridge()
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
private readonly epochs = new Map<string, Map<string, number>>()
private readonly requests = new Map<string, number>()
private epoch = 0
private sessionDirectories = new Map<string, string>() // Per-session directory overrides, such as Agent Manager worktrees.
private sessionGitDirectories = new Map<string, string>() // Stable Git root resolved for each session.
private sessionGitRecoveries = new Set<string>() // Sessions whose older history was scanned for a Git root.
Expand Down Expand Up @@ -717,14 +720,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (this.cachedStats) this.postMessage(this.cachedStats)
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })

// Seed session status map so the Settings panel knows about already-running sessions.
// Must run after webview is ready (postMessage is a no-op before that).
// Only reconcile (reset missing busy→idle) when the map is empty, i.e.
// on the very first seed before any real-time SSE events have arrived.
// On SSE reconnects or webview recreations the live SSE data is
// authoritative and reconciliation risks race-resetting busy sessions.
const reconcile = this.sessionStatusMap.size === 0
void this.seedSessionStatusMap(reconcile)
void this.seedSessionStatusMap()

this.sendRemoteStatus()
}
Expand Down Expand Up @@ -2006,21 +2002,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e))
this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) })
this.client.session
this.sync(sessionID, dir, signal, refresh)
}

private sync(sessionID: string, dir: string, signal?: AbortSignal, refresh?: number): void {
const client = this.client
if (!client) return
const epoch = this.epoch
const request = this.begin(dir)
void client.session
.status({ directory: dir })
.then((r) => {
if (!r.data || signal?.aborted) return
for (const [sid, info] of Object.entries(r.data) as [string, SessionStatus][]) {
if (!this.trackedSessionIds.has(sid)) continue
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: info.type,
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
})
.then((result) => {
if (!result.data || signal?.aborted || !this.latest(dir, request)) return
if (refresh !== undefined && this.refreshes.get(sessionID) !== refresh) return
for (const [sid, status] of Object.entries(result.data) as [string, SessionStatus][]) {
if (!this.trackedSessionIds.has(sid) || !this.accept(sid, status, dir, epoch)) continue
this.publish(sid, status)
}
for (const [sid, current] of this.sessionStatusMap) {
if (result.data[sid] || current === "idle" || !this.trackedSessionIds.has(sid)) continue
const status = { type: "idle" as const }
if (this.accept(sid, status, dir, epoch)) this.publish(sid, status)
}
})
.catch((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e))
.catch((error: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", error))
}

private fetchAndSendSessionModelUsage(sessionID: string, requestID: string): Promise<void> {
Expand Down Expand Up @@ -2352,6 +2357,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.checkpoints.delete(sessionID)
this.revisions.delete(sessionID)
this.refreshes.delete(sessionID)
this.epochs.delete(sessionID)
this.sessionStatusMap.delete(sessionID)
this.costs.onSessionDeleted(sessionID)
const deletedAlertLimit = this.activeAlerts.get(sessionID)
Expand Down Expand Up @@ -2950,19 +2956,65 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}

/**
* Seed sessionStatusMap with current session statuses on connect.
* Without this, the Settings panel (which has no tracked sessions) would see
* busyCount() = 0 for sessions that were already running before it opened.
*
* @param reconcile When true, reset locally-busy sessions absent from the
* server response to idle (crash recovery). Set to false on SSE reconnects
* to avoid a race where a brief HTTP fetch gap causes the spinner to vanish.
*/
private begin(dir: string): number {
const key = [...this.requests.keys()].find((entry) => sameDirectory(entry, dir)) ?? dir
const request = (this.requests.get(key) ?? 0) + 1
this.requests.set(key, request)
return request
}

private latest(dir: string, request: number): boolean {
return [...this.requests].some(([entry, value]) => value === request && sameDirectory(entry, dir))
}

private mark(sessionID: string, dir?: string): void {
const entries = this.epochs.get(sessionID) ?? new Map<string, number>()
const key = dir ? ([...entries.keys()].find((entry) => entry && sameDirectory(entry, dir)) ?? dir) : ""
entries.set(key, ++this.epoch)
this.epochs.set(sessionID, entries)
}

private stale(sessionID: string, dir: string, epoch: number): boolean {
const entries = this.epochs.get(sessionID)
if (!entries) return false
return [...entries].some(([entry, value]) => value > epoch && (!entry || sameDirectory(entry, dir)))
}

private accept(sessionID: string, status: SessionStatus, dir: string, epoch: number): boolean {
if (this.stale(sessionID, dir, epoch)) return false
if (status.type === "idle" && !sameDirectory(this.getWorkspaceDirectory(sessionID), dir)) return false
this.mark(sessionID, dir)
this.aborts.observe(sessionID, status.type, dir)
return true
}

private publish(sessionID: string, status: SessionStatus): void {
const previous = this.sessionStatusMap.get(sessionID)
if ((previous === undefined || previous === "idle") && status.type !== "idle") this.costs.rearm(sessionID)
this.sessionStatusMap.set(sessionID, status.type)
this.streams.flush(sessionID)
this.postMessage({
type: "sessionStatus",
sessionID,
status: status.type,
...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}),
...(status.type === "offline" ? { message: status.message } : {}),
})
}

private async seedSessionStatusMap(reconcile = true): Promise<void> {
if (!this.client || this.connectionState !== "connected") return
const dir = this.getWorkspaceDirectory()
await seedSessionStatuses(this.client, dir, this.sessionStatusMap, (msg) => this.postMessage(msg), reconcile)
const epoch = this.epoch
const request = this.begin(dir)
await seedSessionStatuses(
this.client,
dir,
this.sessionStatusMap,
(message) => this.postMessage(message),
reconcile,
(sessionID, status) => this.latest(dir, request) && this.accept(sessionID, status, dir, epoch),
)
}

/**
Expand Down Expand Up @@ -4141,9 +4193,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleAbort(sessionID?: string): Promise<void> {
const sid = sessionID || this.currentSession?.id
if (!sid || !(await this.stopSession(sid))) return
this.sessionStatusMap.set(sid, "idle")
this.streams.flush(sid)
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
this.mark(sid)
this.publish(sid, { type: "idle" })
}

private async handleRevertSession(sessionID: string, messageID: string, partID?: string): Promise<void> {
Expand Down Expand Up @@ -4677,17 +4728,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// busy-session warning on Save.
if (event.type === "session.status") {
const sid = event.properties.sessionID
const prev = this.sessionStatusMap.get(sid)
if ((prev === undefined || prev === "idle") && event.properties.status.type !== "idle") {
this.costs.rearm(sid)
}
this.sessionStatusMap.set(sid, event.properties.status.type)
this.aborts.observe(sid, event.properties.status.type, directory)
const msg = mapSSEEventToWebviewMessage(event, sid)
if (msg) {
this.streams.flush(sid)
this.postMessage(msg)
}
const status = event.properties.status
this.mark(sid, directory)
this.aborts.observe(sid, status.type, directory)
this.publish(sid, status)
return
}

Expand Down Expand Up @@ -4845,6 +4889,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
this.streams.flush(sessionID)
this.postMessage(next)
const sid = event.type === "session.turn.close" ? event.properties.sessionID : sessionID
if (!sid) return
const status = this.sessionStatusMap.get(sid)
if (!status || status === "idle") return
if (
event.type === "session.turn.close" ||
(event.type === "message.updated" &&
event.properties.info.role === "assistant" &&
event.properties.info.finish === "stop" &&
event.properties.info.time.completed !== undefined)
) {
this.sync(sid, directory ?? this.getWorkspaceDirectory(sid))
}
}

/** Wait until the webview has sent "webviewReady". Resolves immediately when already ready. */
Expand Down Expand Up @@ -5387,6 +5444,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sessionDirectories.clear()
this.anacondaDesktop.dispose()
this.aborts.clear()
this.requests.clear()
this.epochs.clear()
this.sessionStatusMap.clear()
this.ignoreController?.dispose()
this.chatAutocomplete?.dispose()
Expand Down
10 changes: 3 additions & 7 deletions packages/kilo-vscode/src/session-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@ import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client"
* Fetch all current session statuses and seed the provided map + webview.
* Called on connect so the Settings panel knows about already-running sessions
* without waiting for the next session.status SSE event.
*
* When `reconcile` is true (default: first seed), locally-busy sessions absent
* from the server response are reset to idle — covering server crash/restart.
* On SSE reconnects set `reconcile: false` to avoid a race where the HTTP
* fetch briefly returns stale data and the spinner disappears mid-stream.
*/
export async function seedSessionStatuses(
client: KiloClient,
dir: string,
map: Map<string, SessionStatus["type"]>,
post: (msg: unknown) => void,
reconcile = true,
accept?: (sessionID: string, status: SessionStatus) => boolean,
): Promise<void> {
try {
const result = await client.session.status({ directory: dir })
Expand All @@ -24,6 +20,7 @@ export async function seedSessionStatuses(

// Seed/update entries the server knows about
for (const [sid, info] of Object.entries(active) as [string, SessionStatus][]) {
if (accept && !accept(sid, info)) continue
map.set(sid, info.type)
post({
type: "sessionStatus",
Expand All @@ -35,11 +32,10 @@ export async function seedSessionStatuses(

// Reconcile: any locally non-idle session absent from the server response
// means the server lost its in-memory state (crash/restart). Reset to idle.
// Skipped on SSE reconnects — the real-time SSE events are authoritative
// for status transitions and the brief HTTP fetch can race with them.
if (reconcile) {
for (const [sid, status] of map) {
if (status !== "idle" && !active[sid]) {
if (accept && !accept(sid, { type: "idle" })) continue
map.set(sid, "idle")
post({ type: "sessionStatus", sessionID: sid, status: "idle" })
}
Expand Down
Loading
Loading