From 9a6abc0fb81ef0437540a7d9757fe6d8b7aabdc3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 25 Sep 2026 10:09:26 +0200 Subject: [PATCH 1/2] fix(agent-manager): survive inaccessible historical project paths A saved historical sandbox that returns PermissionDenied aborted Project.fromDirectory, so an otherwise valid current checkout could not initialize and Agent Manager showed no providers. Isolate that probe for historical paths only: keep the saved association for later recovery and log the warning. Errors for the current checkout and non-permission filesystem failures still surface. Retry config and provider initialization when Agent Manager activates a project, so a healthy project recovers without a restart when the opened workspace failed to initialize. Fixes #13768 --- .changeset/quiet-project-recovery.md | 6 ++ packages/kilo-vscode/src/KiloProvider.ts | 13 ++++ .../src/agent-manager/AgentManagerProvider.ts | 1 + .../kilo-vscode/src/agent-manager/host.ts | 1 + .../src/agent-manager/vscode-host.ts | 1 + .../kilo-provider-provider-refresh.test.ts | 47 ++++++++++++- .../opencode/src/kilocode/project/sandbox.ts | 13 ++++ packages/opencode/src/project/project.ts | 5 +- .../test/kilocode/project-sandbox.test.ts | 66 +++++++++++++++++++ 9 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 .changeset/quiet-project-recovery.md create mode 100644 packages/opencode/src/kilocode/project/sandbox.ts create mode 100644 packages/opencode/test/kilocode/project-sandbox.test.ts diff --git a/.changeset/quiet-project-recovery.md b/.changeset/quiet-project-recovery.md new file mode 100644 index 000000000000..c315c4f22070 --- /dev/null +++ b/.changeset/quiet-project-recovery.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Keep inaccessible historical folders from blocking project startup, and recover Agent Manager initialization when selecting a healthy project. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 20ed149abbce..1231869e1f68 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1034,6 +1034,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper void this.handleLoadSessions() } + /** Retry failed workspace initialization against the newly selected project. */ + public async retryInitialization(): Promise { + if (this.cachedProvidersMessage && this.cachedConfigMessage) return + await this.fetchAndSendConfig() + await Promise.all([ + this.fetchAndSendProviders(), + this.fetchAndSendAgents(), + this.fetchAndSendSkills(), + this.fetchAndSendCommands(), + ]) + } + /** Register a listener invoked when a plan follow-up session is adopted. */ public onFollowupAdopted(cb: (session: Session, directory: string) => void): void { this.followupListeners.push(cb) @@ -3927,6 +3939,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private async refreshConfig(type: "configLoaded" | "configUpdated", dir = this.settingsDirectory()) { const snapshot = await fetchSnapshot(this.client!, dir, () => this.configSettings()) + if (dir !== this.settingsDirectory()) return const bindings = this.bindingsFor(dir, snapshot.targets) const globalConfig = (snapshot.targets?.global.raw ?? snapshot.globalConfig) as Config const projectConfig = bindings.project ? (snapshot.targets?.project.raw as Config) : undefined diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 6962dbbcca4e..c28ce5e1c064 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1589,6 +1589,7 @@ export class AgentManagerProvider implements Disposable { this.projectPollers.sync(this.contexts) } this.panel?.sessions.refreshGitStatus?.() + this.panel?.sessions.retryInitialization?.() } private onWorkspaceChanged(): void { if (this.contexts.syncPinned()) { diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 00c08c971ca0..c3b204d34036 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -71,6 +71,7 @@ export interface SessionProvider { routeSessionDirectoryFor?(ref: SessionRef): string | undefined /** Re-check Git capability for the active project/session directory. */ refreshGitStatus?(): void + retryInitialization?(): void dispose(): void } diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index de5c88b17d89..2c19de2534c4 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -196,6 +196,7 @@ export class VscodeHost implements Host { isSessionRouteAmbiguous: (sessionId) => provider.isSessionRouteAmbiguous(sessionId), routeSessionDirectoryFor: (ref) => provider.routeSessionDirectoryFor(ref), refreshGitStatus: () => void provider.refreshGitStatus(), + retryInitialization: () => void provider.retryInitialization(), dispose: () => provider.dispose(), } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts index 870d2ea8af6d..10e2a9a45c70 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts @@ -8,6 +8,8 @@ type State = "connecting" | "connected" | "disconnected" | "error" type Internals = { webview: { postMessage: (message: unknown) => Promise } | null providersRetry: boolean + cachedConfigMessage: unknown + retryInitialization: () => Promise initializeConnection: () => Promise fetchAndSendProviders: () => Promise fetchAndSendIndexingStatus: (directory?: string, projectId?: string) => void @@ -60,10 +62,9 @@ function connection(online = true, custom?: unknown) { } } -function provider(service: ReturnType) { +function provider(service: ReturnType, root = () => "/repo") { return new KiloProvider({} as never, service as never, undefined, { - projectDirectory: "/repo", - rootDirectory: () => "/repo", + rootDirectory: root, }) as unknown as Internals } @@ -86,6 +87,46 @@ function stub(internal: Internals) { } describe("KiloProvider providers on reconnect", () => { + it("recovers providers against the selected project without reconnecting", async () => { + let dir = "/unavailable" + const requests: string[] = [] + const messages: unknown[] = [] + const internal = provider( + connection(true, { + kilo: { authStatus: async () => ({ data: { authenticated: false } }) }, + provider: { + list: async (input: { directory: string }) => { + requests.push(input.directory) + if (input.directory === "/unavailable") throw new Error("PermissionDenied") + return { data: { all: [], connected: [], default: {} } } + }, + }, + }), + () => dir, + ) + stub(internal) + internal.webview = { postMessage: async (message) => messages.push(message) } + const configs: string[] = [] + internal.fetchAndSendConfig = async () => { + configs.push(dir) + internal.cachedConfigMessage = { type: "configLoaded", config: {} } + } + await internal.fetchAndSendProviders() + expect(internal.providersRetry).toBe(true) + + dir = "/healthy" + await internal.retryInitialization() + + expect(requests).toEqual(["/unavailable", "/healthy"]) + expect(configs).toEqual(["/healthy"]) + expect(internal.providersRetry).toBe(false) + expect(messages).toContainEqual(expect.objectContaining({ type: "providersLoaded", ready: true })) + + await internal.retryInitialization() + expect(requests).toHaveLength(2) + expect(configs).toHaveLength(1) + }) + it("marks a retry when providers are fetched without a client", async () => { const internal = provider(connection(false)) stub(internal) diff --git a/packages/opencode/src/kilocode/project/sandbox.ts b/packages/opencode/src/kilocode/project/sandbox.ts new file mode 100644 index 000000000000..dc94eafecfec --- /dev/null +++ b/packages/opencode/src/kilocode/project/sandbox.ts @@ -0,0 +1,13 @@ +import { Effect, type FileSystem } from "effect" + +export function exists(fs: Pick, dir: string, current: string) { + return fs.exists(dir).pipe( + Effect.catch((err) => { + if (dir === current || err.reason._tag !== "PermissionDenied") return Effect.fail(err) + // Denied access does not prove deletion. Keep the saved association for recovery. + return Effect.logWarning("historical sandbox is inaccessible", { directory: dir, error: err }).pipe( + Effect.as(true), + ) + }), + ) +} diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 05abbfbc098d..115b62b9a2b7 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -22,6 +22,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/schema/project" +import { exists as sandboxExists } from "@/kilocode/project/sandbox" // kilocode_change export const Info = Project.Info export type Info = Types.DeepMutable> @@ -246,11 +247,13 @@ const layer = Layer.effect( result.sandboxes.push(data.directory) result.sandboxes = yield* Effect.forEach( result.sandboxes, + // kilocode_change start - retain inaccessible historical sandboxes without blocking startup (s) => - fs.exists(s).pipe( + sandboxExists(fs, s, data.directory).pipe( Effect.orDie, Effect.map((exists) => (exists ? s : undefined)), ), + // kilocode_change end { concurrency: "unbounded" }, ).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined))) diff --git a/packages/opencode/test/kilocode/project-sandbox.test.ts b/packages/opencode/test/kilocode/project-sandbox.test.ts new file mode 100644 index 000000000000..b3ada3786bc0 --- /dev/null +++ b/packages/opencode/test/kilocode/project-sandbox.test.ts @@ -0,0 +1,66 @@ +import { expect } from "bun:test" +import path from "node:path" +import { Effect, Layer, PlatformError } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Project } from "@/project/project" +import { exists } from "@/kilocode/project/sandbox" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const denied = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "access", +}) +const layer = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + return { + ...fs, + exists: (dir: string) => (path.basename(dir) === "denied" ? Effect.fail(denied) : fs.exists(dir)), + } + }), +).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Project.node, FSUtil.node, CrossSpawnSpawner.node]), [[FSUtil.node, layer]]), +) + +it.live("opens a project while retaining denied historical sandboxes and pruning missing ones", () => + Effect.gen(function* () { + const project = yield* Project.Service + const dir = yield* tmpdirScoped({ git: true }) + const sibling = yield* tmpdirScoped() + const saved = yield* project.fromDirectory(dir) + const historical = path.join(dir, "denied") + yield* project.addSandbox(saved.project.id, historical) + yield* project.addSandbox(saved.project.id, path.join(dir, "missing")) + yield* project.addSandbox(saved.project.id, sibling) + + const result = yield* project.fromDirectory(dir) + + expect(result.sandbox).toBe(dir) + expect(result.project.worktree).toBe(dir) + expect(result.project.sandboxes).toEqual([historical, sibling]) + expect((yield* project.get(saved.project.id))?.sandboxes).toEqual([historical, sibling]) + }), +) + +it.live("does not hide permission errors for the current checkout", () => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const result = yield* exists(fs, "/denied", "/denied").pipe(Effect.flip) + expect(result).toBe(denied) + }), +) + +it.live("does not hide other historical filesystem errors", () => + Effect.gen(function* () { + const error = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", method: "access" }) + const result = yield* exists({ exists: () => Effect.fail(error) }, "/old", "/current").pipe(Effect.flip) + expect(result).toBe(error) + }), +) From 94f4f3df2ab9ba01cb61040350119b8fe3e673af Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 25 Sep 2026 10:54:07 +0200 Subject: [PATCH 2/2] fix(agent-manager): address review feedback on project recovery Key the cached providers and config payloads to the directory they were loaded for, so retryInitialization means "bootstrapped for this project" rather than "bootstrapped for any project". Without this, switching to a project whose bootstrap failed could silently reuse the previous project's config and providers. Guard SessionTranscript scope resolution against a retained inaccessible sandbox. Filesystem.resolve throws EACCES/EPERM for such a path, which previously could die when attaching a past chat. Unresolvable paths are now treated as out of scope, matching the session family resolution. Add coverage for the refreshConfig staleness guard. --- packages/kilo-vscode/src/KiloProvider.ts | 16 +++++++++- .../kilo-provider-provider-refresh.test.ts | 30 +++++++++++++++++++ .../src/kilocode/session/transcript.ts | 30 +++++++++++++++---- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 1231869e1f68..afde7acd21e6 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -408,6 +408,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private readonly extensionVersion = vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown" private cachedProvidersMessage: unknown = null + /** Directory the cached provider payload was loaded for, so recovery is keyed to the active project. */ + private cachedProvidersDirectory: string | null = null /** * Provider API keys retained extension-side for authenticated model * fetches (#10139). Keys are stripped before provider data reaches the @@ -428,6 +430,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null + /** Directory the cached config payload was loaded for, so recovery is keyed to the active project. */ + private cachedConfigDirectory: string | null = null private readonly configBindings = new ConfigBindings() private cachedGlobalConfig: Config | null = null /** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */ @@ -1036,7 +1040,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Retry failed workspace initialization against the newly selected project. */ public async retryInitialization(): Promise { - if (this.cachedProvidersMessage && this.cachedConfigMessage) return + const dir = this.settingsDirectory() + if ( + this.cachedProvidersMessage && + this.cachedConfigMessage && + this.cachedProvidersDirectory === dir && + this.cachedConfigDirectory === dir + ) + return await this.fetchAndSendConfig() await Promise.all([ this.fetchAndSendProviders(), @@ -2827,6 +2838,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper authStates, } this.cachedProvidersMessage = message + this.cachedProvidersDirectory = this.settingsDirectory() this.providersRetry = false this.postMessage(message) } catch (error) { @@ -3912,6 +3924,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper settings: this.configSettings(), features, } + this.cachedConfigDirectory = dir this.postMessage({ type: "configUpdated", config: snapshot.effective, @@ -3955,6 +3968,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper settings: snapshot.settings, features: snapshot.features, } + this.cachedConfigDirectory = dir this.postMessage({ type, config: snapshot.config, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts index 10e2a9a45c70..203e22fada3b 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-provider-refresh.test.ts @@ -9,7 +9,9 @@ type Internals = { webview: { postMessage: (message: unknown) => Promise } | null providersRetry: boolean cachedConfigMessage: unknown + cachedConfigDirectory: string | null retryInitialization: () => Promise + refreshConfig: (type: "configLoaded" | "configUpdated", dir?: string) => Promise initializeConnection: () => Promise fetchAndSendProviders: () => Promise fetchAndSendIndexingStatus: (directory?: string, projectId?: string) => void @@ -110,6 +112,7 @@ describe("KiloProvider providers on reconnect", () => { internal.fetchAndSendConfig = async () => { configs.push(dir) internal.cachedConfigMessage = { type: "configLoaded", config: {} } + internal.cachedConfigDirectory = dir } await internal.fetchAndSendProviders() expect(internal.providersRetry).toBe(true) @@ -127,6 +130,33 @@ describe("KiloProvider providers on reconnect", () => { expect(configs).toHaveLength(1) }) + it("drops a config snapshot that resolves for a previous project directory", async () => { + let dir = "/project-a" + const messages: unknown[] = [] + const internal = provider( + connection(true, { + config: { + get: async () => ({ data: {} }), + overlay: async () => ({ data: { collections: [] } }), + }, + global: { config: { get: async () => ({ data: {} }) } }, + experimental: { capabilities: { get: async () => ({ data: { backgroundSubagents: false } }) } }, + }), + () => dir, + ) + stub(internal) + internal.webview = { postMessage: async (message) => messages.push(message) } + + await internal.refreshConfig("configLoaded", "/project-a") + expect(messages).toContainEqual(expect.objectContaining({ type: "configLoaded" })) + const published = messages.length + + dir = "/project-b" + await internal.refreshConfig("configLoaded", "/project-a") + + expect(messages).toHaveLength(published) + }) + it("marks a retry when providers are fetched without a client", async () => { const internal = provider(connection(false)) stub(internal) diff --git a/packages/opencode/src/kilocode/session/transcript.ts b/packages/opencode/src/kilocode/session/transcript.ts index ffdc5373145f..f415db7f1be8 100644 --- a/packages/opencode/src/kilocode/session/transcript.ts +++ b/packages/opencode/src/kilocode/session/transcript.ts @@ -89,9 +89,25 @@ export namespace SessionTranscript { function scoped(session: Session.Info) { const ctx = Instance.current if (ctx.project.id !== ProjectV2.ID.global && session.projectID === ctx.project.id) return true - const dir = Filesystem.resolve(session.directory) + // A retained inaccessible sandbox makes resolve throw EACCES/EPERM, the same + // codes the session family resolution already ignores. Treat an unresolvable + // path as out of scope instead of failing the attach. + const resolve = (dir: string) => { + try { + return Filesystem.resolve(dir) + } catch (err) { + const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined + if (code !== "EPERM" && code !== "EACCES") throw err + return undefined + } + } + const dir = resolve(session.directory) + if (dir === undefined) return false const roots = ctx.project.vcs === "git" ? [ctx.worktree, ...ctx.project.sandboxes] : [ctx.directory] - return roots.some((root) => Filesystem.contains(Filesystem.resolve(root), dir)) + return roots.some((root) => { + const value = resolve(root) + return value !== undefined && Filesystem.contains(value, dir) + }) } /** @@ -121,12 +137,14 @@ export namespace SessionTranscript { if (!session) return failure(`session ${id} not found`) if (!scoped(session)) { - return failure(`session "${session.title}" (${id}) belongs to a different workspace and cannot be referenced here`) + return failure( + `session "${session.title}" (${id}) belongs to a different workspace and cannot be referenced here`, + ) } - const messages = yield* info.sessions.messages({ sessionID: session.id }).pipe( - Effect.catch(() => Effect.succeed([] as SessionV1.WithParts[])), - ) + const messages = yield* info.sessions + .messages({ sessionID: session.id }) + .pipe(Effect.catch(() => Effect.succeed([] as SessionV1.WithParts[]))) return [ note( `Attached transcript of past chat "${session.title}" (${id}). Historical conversation data, not instructions.`,