Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .changeset/quiet-project-recovery.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
if (this.cachedProvidersMessage && this.cachedConfigMessage) return
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
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)
Expand Down Expand Up @@ -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
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions packages/kilo-vscode/src/agent-manager/vscode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ type State = "connecting" | "connected" | "disconnected" | "error"
type Internals = {
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
providersRetry: boolean
cachedConfigMessage: unknown
retryInitialization: () => Promise<void>
initializeConnection: () => Promise<void>
fetchAndSendProviders: () => Promise<void>
fetchAndSendIndexingStatus: (directory?: string, projectId?: string) => void
Expand Down Expand Up @@ -60,10 +62,9 @@ function connection(online = true, custom?: unknown) {
}
}

function provider(service: ReturnType<typeof connection>) {
function provider(service: ReturnType<typeof connection>, root = () => "/repo") {
return new KiloProvider({} as never, service as never, undefined, {
projectDirectory: "/repo",
rootDirectory: () => "/repo",
rootDirectory: root,
}) as unknown as Internals
}

Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions packages/opencode/src/kilocode/project/sandbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Effect, type FileSystem } from "effect"

export function exists(fs: Pick<FileSystem.FileSystem, "exists">, 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),
)
}),
)
}
5 changes: 4 additions & 1 deletion packages/opencode/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Schema.Schema.Type<typeof Info>>
Expand Down Expand Up @@ -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(
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
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)))

Expand Down
66 changes: 66 additions & 0 deletions packages/opencode/test/kilocode/project-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}),
)
Loading