fix(agent-manager): preserve worktree sessions when backend listing fails (#8902)

When the CLI backend is temporarily unhealthy, session.list() for worktree
directories throws and the catch returns []. The webview reconciliation then
deletes those sessions from the store, making them disappear. Track failed
directories and pass preserveSessionIds so the webview skips deletion for
sessions whose absence is transient.

Closes #7466
This commit is contained in:
Marius
2026-04-15 11:07:58 +02:00
committed by GitHub
parent 0679fdfcd8
commit fb6beddffc
4 changed files with 99 additions and 2 deletions
@@ -195,10 +195,12 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise<string |
const sessions = await list(ctx.workspaceDirectory)
const projectID = sessions[0]?.projectID
const worktreeDirs = new Set(ctx.sessionDirectories.values())
const failed = new Set<string>()
const extra = await Promise.all(
[...worktreeDirs].map((dir) =>
list(dir).catch((err: unknown) => {
console.error(`[Kilo] Failed to list sessions for ${dir}:`, err)
failed.add(dir)
return [] as Session[]
}),
),
@@ -212,9 +214,19 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise<string |
}
}
// Sessions whose worktree directories failed to list — the webview must
// not delete these during reconciliation since the absence is transient.
const preserve: string[] = []
if (failed.size) {
for (const [sid, dir] of ctx.sessionDirectories) {
if (failed.has(dir)) preserve.push(sid)
}
}
ctx.postMessage({
type: "sessionsLoaded",
sessions: sessions.map((s) => sessionToWebview(s)),
...(preserve.length ? { preserveSessionIds: preserve } : {}),
})
return projectID
@@ -157,6 +157,86 @@ describe("KiloProvider pending session refresh", () => {
expect((sent[0] as { sessions: { id: string }[] }).sessions.map((s) => s.id)).toEqual(["ses_worktree"])
})
it("preserves session ids when worktree directory listing fails", async () => {
const sent: unknown[] = []
const ctx = createContext({
connectionState: "connected",
sessionDirectories: new Map([
["ses_wt1", "/worktree1"],
["ses_wt2", "/worktree2"],
]),
listSessions: async (dir) => {
if (dir === "/repo") {
return [
{
id: "ses_root",
projectID: "project",
title: "root",
directory: "/repo",
time: { created: 1, updated: 1 },
},
] as never
}
if (dir === "/worktree1") throw new Error("backend not ready")
return [
{
id: "ses_wt2",
projectID: "project",
title: "wt2",
directory: "/worktree2",
time: { created: 2, updated: 2 },
},
] as never
},
postMessage: (msg) => sent.push(msg),
})
await loadSessions(ctx)
expect(sent).toHaveLength(1)
const msg = sent[0] as { sessions: { id: string }[]; preserveSessionIds?: string[] }
expect(msg.sessions.map((s) => s.id)).toEqual(["ses_root", "ses_wt2"])
expect(msg.preserveSessionIds).toEqual(["ses_wt1"])
})
it("omits preserveSessionIds when all directories succeed", async () => {
const sent: unknown[] = []
const ctx = createContext({
connectionState: "connected",
sessionDirectories: new Map([["ses_wt", "/worktree"]]),
listSessions: async (dir) => {
if (dir === "/repo") {
return [
{
id: "ses_root",
projectID: "project",
title: "root",
directory: "/repo",
time: { created: 1, updated: 1 },
},
] as never
}
return [
{
id: "ses_wt",
projectID: "project",
title: "wt",
directory: "/worktree",
time: { created: 2, updated: 2 },
},
] as never
},
postMessage: (msg) => sent.push(msg),
})
await loadSessions(ctx)
expect(sent).toHaveLength(1)
const msg = sent[0] as { sessions: { id: string }[]; preserveSessionIds?: string[] }
expect(msg.sessions.map((s) => s.id)).toEqual(["ses_root", "ses_wt"])
expect(msg.preserveSessionIds).toBeUndefined()
})
it("flushes deferred refresh via flushPendingSessionRefresh", async () => {
const { calls, fn } = createListSessions()
const ctx = createContext()
@@ -687,7 +687,7 @@ export const SessionProvider: ParentComponent = (props) => {
break
case "sessionsLoaded":
handleSessionsLoaded(message.sessions)
handleSessionsLoaded(message.sessions, message.preserveSessionIds)
break
case "sessionUpdated":
@@ -1120,16 +1120,20 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("todos", sessionID, items)
}
function handleSessionsLoaded(loaded: SessionInfo[]) {
function handleSessionsLoaded(loaded: SessionInfo[], preserve?: string[]) {
const kept = preserve?.length ? new Set(preserve) : undefined
batch(() => {
// Reconcile: remove sessions not in the loaded list to prevent stale
// entries from other projects accumulating in the store.
// Sessions whose worktree directories failed to list are preserved —
// their absence is transient, not a real deletion.
const ids = new Set(loaded.map((s) => s.id))
setStore(
"sessions",
produce((sessions) => {
for (const id of Object.keys(sessions)) {
if (id.startsWith("cloud:")) continue
if (kept?.has(id)) continue
if (!ids.has(id)) delete sessions[id]
}
}),
@@ -580,6 +580,7 @@ export interface MessageCreatedMessage {
export interface SessionsLoadedMessage {
type: "sessionsLoaded"
sessions: SessionInfo[]
preserveSessionIds?: string[]
}
export interface CloudSessionsLoadedMessage {