From d842b975abe21bde9462fcf6bfbfbcae22deb493 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 4 Sep 2026 10:24:29 +0200 Subject: [PATCH 1/2] fix(agent-manager): clear stale child activity --- .changeset/fix-stale-worktree-status.md | 5 ++ packages/kilo-vscode/src/KiloProvider.ts | 58 +++++++++++-- .../tests/unit/kilo-provider-followup.test.ts | 85 +++++++++++++++++-- .../unit/kilo-provider-load-messages.test.ts | 49 ++++++++++- 4 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 .changeset/fix-stale-worktree-status.md diff --git a/.changeset/fix-stale-worktree-status.md b/.changeset/fix-stale-worktree-status.md new file mode 100644 index 00000000000..019483e50d1 --- /dev/null +++ b/.changeset/fix-stale-worktree-status.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Clear stale Agent Manager running indicators when a background agent finishes while another project is selected. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b2763161128..1458891d6e4 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -418,6 +418,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly requests = new Map() private epoch = 0 private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. + private readonly owners = new Map() private sessionGitDirectories = new Map() // Stable Git root resolved for each session. private sessionGitRecoveries = new Set() // Sessions whose older history was scanned for a Git root. private readonly aborts = new SessionAbort() @@ -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) } @@ -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. */ @@ -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 @@ -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) } @@ -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 })), @@ -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) @@ -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) @@ -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 @@ -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", @@ -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) && @@ -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 @@ -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() diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 2a43e5faa25..61c4d40375f 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -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 } | null trackedSessionIds: Set + syncedChildSessions: Set + sessionDirectories: Map + sessionStatusMap: Map + owners: Map currentSession: Session | null projectID: string | undefined isWebviewReady: boolean pendingFollowup: { dir: string; time: number } | null handleLoadMessages: (sessionID: string) => Promise + releaseChildSession: (sessionID: string) => void handleEvent: (event: Event, directory?: string) => void refreshGitStatus: (directory?: string) => Promise refreshGitStatusFromParts: (parts: unknown[], sessionID?: string) => Promise @@ -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 @@ -96,6 +105,7 @@ function connection() { resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined), recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, + pruneSession: () => undefined, } } @@ -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, { diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 46018e645a6..8fa4b1b0c8b 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -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" @@ -240,7 +241,9 @@ type ProviderInternals = { contextSessionID: string | undefined sessionDirectories: Map sessionStatusMap: Map + owners: Map trackedSessionIds: Set + syncedChildSessions: Set removedSessionIds: Set openSessionIds: Set draftSessions: Map @@ -266,13 +269,18 @@ type ProviderInternals = { handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise refreshGitStatus: (directory?: string, sessionID?: string) => Promise handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise + handleSyncSession: (sid: string, parent?: string) => Promise + releaseChildSession: (sid: string) => void handleDeleteSession: (sid: string) => Promise handleDeleteMessage: (sid: string, mid: string, rid?: string) => Promise } -function makeProvider(client: ReturnType | null) { +function makeProvider( + client: ReturnType | null, + opts?: ConstructorParameters[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[] = [] @@ -578,6 +586,43 @@ 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(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", () => { From f936e8b2200eee21debf77c71dc9c1cca63f8941 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 4 Sep 2026 10:40:51 +0200 Subject: [PATCH 2/2] test(agent-manager): cover released child unblock --- .../kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 8fa4b1b0c8b..7726cbcf7ca 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -608,6 +608,7 @@ describe("KiloProvider session status reconciliation", () => { 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" }) })