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-worktree-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Clear stale Agent Manager running indicators when a background agent finishes while another project is selected.
58 changes: 52 additions & 6 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
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 readonly owners = new Map<string, { dir: string; project: string }>()
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.
private readonly aborts = new SessionAbort()
Expand Down Expand Up @@ -861,6 +862,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const current = this.sessionDirectories.get(sessionId) ?? this.getRootDirectory()
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), current)
this.sessionDirectories.delete(sessionId)
this.owners.delete(sessionId)
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
}

Expand All @@ -877,6 +879,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/** Drop a project and all its session/worktree routes from the route service. */
public unregisterProjectRoute(projectId: string): void {
this.opts.routeService?.unregisterProject(projectId)
for (const [sid, owner] of this.owners) {
if (owner.project === projectId) this.owners.delete(sid)
}
}

/** Register a worktree directory under a project. */
Expand Down Expand Up @@ -1793,10 +1798,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(payload, directory) => {
if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return false
if (!directory && isEventFromForeignProject(payload, this.projectID)) return false
const event = unwrapSyncEvent(payload)
if (!event) return false
if (
directory &&
directory !== "global" &&
!this.isCurrentProjectDirectory(directory) &&
!this.terminal(event, directory)
)
return false
if (!directory && isEventFromForeignProject(payload, this.projectID)) return false

// Remote status events are global and should always pass through
if (event.type === "kilo-sessions.remote-status-changed") return true
Expand Down Expand Up @@ -2050,11 +2061,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
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
if ((!this.trackedSessionIds.has(sid) && !this.owned(sid, dir)) || !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
if (result.data[sid] || current === "idle" || (!this.trackedSessionIds.has(sid) && !this.owned(sid, dir)))
continue
const status = { type: "idle" as const }
if (this.accept(sid, status, dir, epoch)) this.publish(sid, status)
}
Expand Down Expand Up @@ -2194,6 +2207,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const project = this.opts.projectQualifier?.()
if (project && this.opts.routeService) {
this.owners.set(sessionID, { dir: workspaceDir, project: project.projectId })
}
const [info, history] = await Promise.all([
retry(() => this.client!.session.get({ sessionID, directory: workspaceDir }, { throwOnError: true })),
retry(() => this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true })),
Expand Down Expand Up @@ -2240,6 +2257,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return
}
if (!this.syncedChildSessions.delete(sessionID)) return
const status = this.sessionStatusMap.get(sessionID)
if (status !== "busy" && status !== "retry") this.owners.delete(sessionID)
this.trackedSessionIds.delete(sessionID)
this.streams.drop(sessionID)
this.visibleTaskStreams.delete(sessionID)
Expand Down Expand Up @@ -2385,6 +2404,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.syncedChildSessions.delete(sessionID)
this.inspectorSessionIds.delete(sessionID)
this.sessionDirectories.delete(sessionID)
this.owners.delete(sessionID)
this.sessionGitDirectories.delete(sessionID)
this.sessionGitRecoveries.delete(sessionID)
this.aborts.delete(sessionID)
Expand Down Expand Up @@ -3035,7 +3055,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper

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
const owner = this.owners.get(sessionID)?.dir ?? this.getWorkspaceDirectory(sessionID)
if (status.type === "idle" && !sameDirectory(owner, dir)) return false
this.mark(sessionID, dir)
this.aborts.observe(sessionID, status.type, dir)
return true
Expand All @@ -3045,6 +3066,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const previous = this.sessionStatusMap.get(sessionID)
if ((previous === undefined || previous === "idle") && status.type !== "idle") this.costs.rearm(sessionID)
this.sessionStatusMap.set(sessionID, status.type)
if ((status.type === "idle" || status.type === "offline") && !this.syncedChildSessions.has(sessionID)) {
this.owners.delete(sessionID)
}
this.streams.flush(sessionID)
this.postMessage({
type: "sessionStatus",
Expand Down Expand Up @@ -4781,7 +4805,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
if (directory && directory !== "global" && !this.isCurrentProjectDirectory(directory)) return
if (
directory &&
directory !== "global" &&
!this.isCurrentProjectDirectory(directory) &&
!this.terminal(event, directory)
)
return
if (
this.projectID &&
(!this.opts.projectQualifier || !directory) &&
Expand Down Expand Up @@ -5227,6 +5257,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return dirs.some((dir) => sameDirectory(dir, directory))
}

private owned(sessionID: string, directory?: string): boolean {
if (!directory || directory === "global" || this.syncedChildSessions.has(sessionID)) return false
const owner = this.owners.get(sessionID)
return owner !== undefined && sameDirectory(owner.dir, directory)
}

private terminal(event: ProviderEvent, directory?: string): boolean {
if (event.type === "session.status") {
const type = event.properties.status.type
if (type === "idle" || type === "offline") return this.owned(event.properties.sessionID, directory)
}
if (event.type === "session.deleted") return this.owned(event.properties.sessionID, directory)
return false
}

private isCurrentProjectSession(sessionID: string): boolean {
if (!this.opts.projectQualifier || !this.opts.routeService) return true
if (this.isSessionRouteAmbiguous(sessionID)) return false
Expand Down Expand Up @@ -5518,6 +5563,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.inspectorSessionIds.clear()
this.draftSessions.clear()
this.sessionDirectories.clear()
this.owners.clear()
this.anacondaDesktop.dispose()
this.aborts.clear()
this.requests.clear()
Expand Down
85 changes: 79 additions & 6 deletions packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import type { Event, Session } from "@kilocode/sdk/v2/client"

// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider } = await import("../../src/KiloProvider")
const { ProjectRouteService } = await import("../../src/agent-manager/project/route")

type Internals = {
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
trackedSessionIds: Set<string>
syncedChildSessions: Set<string>
sessionDirectories: Map<string, string>
sessionStatusMap: Map<string, string>
owners: Map<string, { dir: string; project: string }>
currentSession: Session | null
projectID: string | undefined
isWebviewReady: boolean
pendingFollowup: { dir: string; time: number } | null
handleLoadMessages: (sessionID: string) => Promise<void>
releaseChildSession: (sessionID: string) => void
handleEvent: (event: Event, directory?: string) => void
refreshGitStatus: (directory?: string) => Promise<void>
refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise<boolean>
Expand Down Expand Up @@ -63,18 +69,21 @@ function info(input: { id: string; projectID: string; directory: string }): Sess
}

function connection() {
let filter: ((event: Event) => boolean) | undefined
let listener: ((event: Event) => void) | undefined
let filter: ((event: Event, directory?: string) => boolean) | undefined
let listener: ((event: Event, directory?: string) => void) | undefined

return {
emit(event: Event) {
emit(event: Event, directory?: string) {
if (!filter || !listener) throw new Error("expected SSE subscription")
if (!filter(event)) return
listener(event)
if (!filter(event, directory)) return
listener(event, directory)
},
connect: async () => {},
getClient: () => ({}) as never,
onEventFiltered: (next: (event: Event) => boolean, cb: (event: Event) => void) => {
onEventFiltered: (
next: (event: Event, directory?: string) => boolean,
cb: (event: Event, directory?: string) => void,
) => {
filter = next
listener = cb
return () => undefined
Expand All @@ -96,6 +105,7 @@ function connection() {
resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined),
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
pruneSession: () => undefined,
}
}

Expand All @@ -106,6 +116,69 @@ function git() {
}

describe("KiloProvider follow-up sessions", () => {
it("accepts terminal status for a released child from an inactive project", async () => {
const service = connection()
let root = "/repo/project-a"
const routes = new ProjectRouteService()
const provider = new KiloProvider({} as never, service as never, undefined, {
rootDirectory: () => root,
projectQualifier: () => ({ projectId: root }),
routeService: routes,
})
const internal = provider as unknown as Internals
const sent: unknown[] = []
const child = "ses-child"
internal.webview = {
postMessage: async (message: unknown) => {
sent.push(message)
return true
},
}
internal.syncWebviewState = async () => {}
internal.flushPendingSessionRefresh = async () => {}
internal.fetchAndSendProviders = async () => {}
internal.fetchAndSendAgents = async () => {}
internal.fetchAndSendSkills = async () => {}
internal.fetchAndSendCommands = async () => {}
internal.fetchAndSendConfig = async () => {}
internal.fetchAndSendNotifications = async () => {}
internal.seedSessionStatusMap = async () => {}
internal.sendNotificationSettings = () => {}
internal.startStatsPolling = () => {}
await internal.initializeConnection()

internal.sessionDirectories.set(child, root)
internal.owners.set(child, { dir: root, project: root })
internal.syncedChildSessions.add(child)
internal.trackedSessionIds.add(child)
service.emit({ type: "session.status", properties: { sessionID: child, status: { type: "busy" } } } as Event, root)
internal.releaseChildSession(child)
expect(internal.trackedSessionIds.has(child)).toBe(false)
expect(internal.sessionDirectories.has(child)).toBe(false)
expect(internal.owners.get(child)).toEqual({ dir: "/repo/project-a", project: "/repo/project-a" })

root = "/repo/project-b"
service.emit(
{ type: "session.status", properties: { sessionID: child, status: { type: "idle" } } } as Event,
"/repo/project-c",
)
expect(internal.sessionStatusMap.get(child)).toBe("busy")
const count = sent.length
service.emit(
{ type: "session.status", properties: { sessionID: child, status: { type: "retry", attempt: 1 } } } as Event,
"/repo/project-a",
)
expect(sent).toHaveLength(count)
service.emit(
{ type: "session.status", properties: { sessionID: child, status: { type: "idle" } } } as Event,
"/repo/project-a",
)

expect(internal.sessionStatusMap.get(child)).toBe("idle")
expect(internal.owners.has(child)).toBe(false)
expect(sent).toContainEqual({ type: "sessionStatus", sessionID: child, status: "idle" })
})

it("scopes shared session events to the active project directory", () => {
const service = connection()
const provider = new KiloProvider({} as never, service as never, undefined, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AbortRequest } from "../../webview-ui/src/types/messages/webview-m

// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider, unwrapSyncEvent } = await import("../../src/KiloProvider")
const { ProjectRouteService } = await import("../../src/agent-manager/project/route")

type State = "connecting" | "connected" | "disconnected" | "error"

Expand Down Expand Up @@ -240,7 +241,9 @@ type ProviderInternals = {
contextSessionID: string | undefined
sessionDirectories: Map<string, string>
sessionStatusMap: Map<string, string>
owners: Map<string, { dir: string; project: string }>
trackedSessionIds: Set<string>
syncedChildSessions: Set<string>
removedSessionIds: Set<string>
openSessionIds: Set<string>
draftSessions: Map<string, { sid: string; dir: string; expires: number }>
Expand All @@ -266,13 +269,18 @@ type ProviderInternals = {
handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise<void>
refreshGitStatus: (directory?: string, sessionID?: string) => Promise<void>
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
handleSyncSession: (sid: string, parent?: string) => Promise<void>
releaseChildSession: (sid: string) => void
handleDeleteSession: (sid: string) => Promise<void>
handleDeleteMessage: (sid: string, mid: string, rid?: string) => Promise<void>
}

function makeProvider(client: ReturnType<typeof createClient> | null) {
function makeProvider(
client: ReturnType<typeof createClient> | null,
opts?: ConstructorParameters<typeof KiloProvider>[3],
) {
const connection = createConnection(client)
const provider = new KiloProvider({} as never, connection as never)
const provider = new KiloProvider({} as never, connection as never, undefined, opts)
const internal = provider as unknown as ProviderInternals
internal.connectionState = client ? "connected" : "disconnected"
const sent: unknown[] = []
Expand Down Expand Up @@ -578,6 +586,44 @@ describe("KiloProvider session status reconciliation", () => {

expect(internal.sessionStatusMap.get("s1")).toBe("busy")
})

it("reconciles a released child from its owning directory snapshot", async () => {
const client = createClient({ sessionData: { ...mkSession(), id: "child" } })
const routes = new ProjectRouteService()
const { internal, sent } = makeProvider(client, {
rootDirectory: () => "/repo",
projectQualifier: () => ({ projectId: "project" }),
routeService: routes,
})
internal.sessionDirectories.set("parent", "/repo/worktree")
await internal.handleSyncSession("child", "parent")
internal.sessionStatusMap.set("child", "busy")
internal.releaseChildSession("child")

internal.refreshSessionDetails("parent", "/repo")
await Bun.sleep(0)
expect(internal.sessionStatusMap.get("child")).toBe("busy")

internal.refreshSessionDetails("parent", "/repo/worktree")
await Bun.sleep(0)

expect(internal.sessionStatusMap.get("child")).toBe("idle")
expect(["busy", "retry", "waiting"].includes(internal.sessionStatusMap.get("child") ?? "idle")).toBe(false)
expect(internal.owners.has("child")).toBe(false)
expect(sent).toContainEqual({ type: "sessionStatus", sessionID: "child", status: "idle" })
})

it("does not retain child ownership outside multi-project providers", async () => {
const client = createClient({ sessionData: { ...mkSession(), id: "child" } })
const { internal } = makeProvider(client)
internal.sessionDirectories.set("parent", "/repo/worktree")
await internal.handleSyncSession("child", "parent")
internal.sessionStatusMap.set("child", "busy")

internal.releaseChildSession("child")

expect(internal.owners.has("child")).toBe(false)
})
})

describe("KiloProvider sandbox status", () => {
Expand Down
Loading