diff --git a/.changeset/project-scoped-reload.md b/.changeset/project-scoped-reload.md new file mode 100644 index 00000000000..b0dbbbea3ed --- /dev/null +++ b/.changeset/project-scoped-reload.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": patch +--- + +Reload the entire project for `/reload` and the reload actions. A reload from an Agent Manager worktree now reboots every loaded instance of the same project, so a project config change applies to the main checkout and all worktrees. The reload is refused while any session in the project is running. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 89c550f9a7e..a12731d2088 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -133,7 +133,7 @@ The `kilo console` command and its browser interface are deprecated and will be | `/status` | - | View status | | `/themes` | - | Switch theme | | `/help` | - | Show help | -| `/reload` | - | Reload config, skills, agents, and commands from disk | +| `/reload` | - | Reload every instance of the project from disk (config, skills, agents, and commands) | | `/editor` | - | Open external editor | | `/auto-approve` | `/autoapprove`, `/approve-all`, `/approveall` | Toggle auto-approve mode for all permission prompts (saved to global config) | | `/caffeinate` | `/caffenate` | Toggle Keep Awake: prevent system sleep while Kilo sessions run | diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index c5cd966e043..a86a35049be 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4770,7 +4770,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ]) } - /** Reload config, skills, agents, and commands from disk by rebooting the instance. */ + /** Reload config, skills, agents, and commands from disk by rebooting the project's instances. */ private async handleReload(): Promise { if (!this.client) { console.warn("[Kilo New] handleReload: no client connection") @@ -4786,7 +4786,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper cause && typeof cause === "object" && "status" in cause ? (cause as { status?: number }).status : undefined if (status === 409) { vscode.window.showWarningMessage( - "Cannot reload while a session is running. Wait for it to finish or abort it first.", + "Cannot reload while a session is running in this project. Wait for it to finish or abort it first.", ) return } @@ -4798,7 +4798,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.clearCommandsCache() if (!sameDirectory(dir, this.getWorkspaceDirectory())) { await this.reloadAfterAuthChange() + return } + await Promise.all([ + this.fetchAndSendConfig(), + this.fetchAndSendAgents(), + this.fetchAndSendSkills(), + this.fetchAndSendCommands(), + ]) } /** Public reload entry point for VS Code commands. */ diff --git a/packages/opencode/src/kilocode/project/reload.ts b/packages/opencode/src/kilocode/project/reload.ts new file mode 100644 index 00000000000..5f98cba2271 --- /dev/null +++ b/packages/opencode/src/kilocode/project/reload.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect" +import { InstanceStore } from "@/project/instance-store" + +/** + * Reload every loaded instance that belongs to a project. + * + * Failures for the directory the request came from propagate so callers still + * see a failed reboot. Failures for sibling instances are logged and skipped. + */ +export const reloadProject = ( + store: InstanceStore.Interface, + projectID: string, + requestDirectory: string, +): Effect.Effect => + Effect.gen(function* () { + for (const ctx of yield* store.list()) { + if (String(ctx.project.id) !== projectID) continue + const reload = store.reload({ directory: ctx.directory, worktree: ctx.worktree, project: ctx.project }) + if (ctx.directory === requestDirectory) { + yield* reload + continue + } + yield* reload.pipe( + Effect.catchCause((cause) => + Effect.logWarning("project instance reload failed", { directory: ctx.directory, cause }), + ), + ) + } + }) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts index 64d04a598d7..ce7cc0613b9 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts @@ -24,9 +24,9 @@ export const InstanceReloadApi = HttpApi.make("instance-reload") }).annotateMerge( OpenApi.annotations({ identifier: "instance.reload", - summary: "Reload instance", + summary: "Reload project", description: - "Atomically dispose and reboot the current Kilo instance, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if a session is actively running.", + "Atomically dispose and reboot every loaded instance of the project, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if any session in the project is actively running.", }), ), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts index 4532b945f70..e092f23e14f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts @@ -1,4 +1,5 @@ import * as InstanceState from "@/effect/instance-state" +import { reloadProject } from "@/kilocode/project/reload" import { InstanceStore } from "@/project/instance-store" import { SessionStatus } from "@/session/status" import { ConflictError } from "@/server/routes/instance/httpapi/errors" @@ -16,23 +17,19 @@ export function hasActiveSession(statuses: Map): export const instanceReloadHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance-reload", (handlers) => Effect.gen(function* () { - const status = yield* SessionStatus.Service const store = yield* InstanceStore.Service const reload = Effect.fn("InstanceReloadHttpApi.reload")(function* () { const ctx = yield* InstanceState.context - if (hasActiveSession(yield* status.list())) { + if (hasActiveSession(yield* SessionStatus.listAll())) { return yield* Effect.fail( new ConflictError({ - message: "Cannot reload while a session is running. Wait for it to finish or abort it first.", + message: + "Cannot reload while a session is running in this project. Wait for it to finish or abort it first.", }), ) } - yield* store.reload({ - directory: ctx.directory, - worktree: ctx.worktree, - project: ctx.project, - }) + yield* reloadProject(store, String(ctx.project.id), ctx.directory) return true }) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index be56d0406a6..3927f55d9a2 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -24,6 +24,9 @@ export interface Interface { readonly disposeDirectory: (directory: string) => Effect.Effect readonly disposeAll: () => Effect.Effect readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect + // kilocode_change start + readonly list: () => Effect.Effect + // kilocode_change end } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -223,11 +226,28 @@ const layer: Layer.Layer(input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + // kilocode_change start - loaded instance contexts for project-scoped operations + const list = (): Effect.Effect => + Effect.forEach([...cache.values()], (entry) => + Deferred.isDone(entry.deferred).pipe( + Effect.flatMap((done) => + done + ? Deferred.await(entry.deferred).pipe( + Effect.exit, + Effect.map((exit) => (Exit.isSuccess(exit) ? exit.value : undefined)), + ) + : Effect.succeed(undefined), + ), + ), + ).pipe(Effect.map((contexts) => contexts.filter((ctx): ctx is InstanceContext => ctx !== undefined))) + // kilocode_change end + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) return Service.of({ load, reload, + list, // kilocode_change dispose, disposeDirectory, disposeAll, diff --git a/packages/opencode/test/kilocode/instance-store-project-reload.test.ts b/packages/opencode/test/kilocode/instance-store-project-reload.test.ts new file mode 100644 index 00000000000..f2501ce571d --- /dev/null +++ b/packages/opencode/test/kilocode/instance-store-project-reload.test.ts @@ -0,0 +1,99 @@ +import { describe, expect } from "bun:test" +import { mkdtemp, realpath, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Effect, Exit } from "effect" +import { reloadProject } from "@/kilocode/project/reload" +import type { InstanceContext } from "@/project/instance-context" +import type { Project } from "@/project/project" +import { InstanceStore } from "@/project/instance-store" +import { testEffect } from "../lib/effect" +import { testInstanceStoreLayer } from "../fixture/fixture" + +const it = testEffect(testInstanceStoreLayer) + +const temporary = Effect.acquireRelease( + Effect.promise(async () => await realpath(await mkdtemp(join(tmpdir(), "opencode-test-")))), + (dir) => Effect.promise(() => rm(dir, { recursive: true, force: true })), +) + +const makeProject = (id: string, worktree: string): Project.Info => ({ + id: ProjectV2.ID.make(id), + worktree, + sandboxes: [], + time: { created: 0, updated: 0 }, +}) + +const context = (dir: string, id: string): InstanceContext => ({ + directory: dir, + worktree: dir, + project: makeProject(id, dir), +}) + +const stub = (contexts: InstanceContext[], reload: InstanceStore.Interface["reload"]) => + ({ list: () => Effect.succeed(contexts), reload }) as unknown as InstanceStore.Interface + +describe("reloadProject", () => { + it.live("reloads every loaded instance of the project and leaves other projects alone", () => + Effect.gen(function* () { + const dirA = yield* temporary + const dirB = yield* temporary + const dirC = yield* temporary + const store = yield* InstanceStore.Service + + const a = yield* store.load({ directory: dirA, worktree: dirA, project: makeProject("proj_reload_shared", dirA) }) + const b = yield* store.load({ directory: dirB, worktree: dirB, project: makeProject("proj_reload_shared", dirB) }) + const c = yield* store.load({ directory: dirC, worktree: dirC, project: makeProject("proj_reload_other", dirC) }) + + yield* reloadProject(store, "proj_reload_shared", dirA) + + expect(yield* store.load({ directory: dirA })).not.toBe(a) + expect(yield* store.load({ directory: dirB })).not.toBe(b) + expect(yield* store.load({ directory: dirC })).toBe(c) + }), + ) + + it.live("is a no-op for a project with no loaded instances", () => + Effect.gen(function* () { + const dir = yield* temporary + const store = yield* InstanceStore.Service + + const loaded = yield* store.load({ directory: dir, worktree: dir, project: makeProject("proj_reload_solo", dir) }) + + yield* reloadProject(store, "proj_reload_missing", dir) + + expect(yield* store.load({ directory: dir })).toBe(loaded) + }), + ) + + it.live("propagates a failure for the request directory", () => + Effect.gen(function* () { + const dirA = "/tmp/reload-request-a" + const dirB = "/tmp/reload-request-b" + const contexts = [context(dirA, "proj_reload_stub"), context(dirB, "proj_reload_stub")] + const store = stub(contexts, (input) => + input.directory === dirA ? Effect.die(new Error("request reload failed")) : Effect.succeed(contexts[1]!), + ) + + const exit = yield* reloadProject(store, "proj_reload_stub", dirA).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.live("skips a failure for a sibling directory", () => + Effect.gen(function* () { + const dirA = "/tmp/reload-sibling-a" + const dirB = "/tmp/reload-sibling-b" + const contexts = [context(dirA, "proj_reload_stub"), context(dirB, "proj_reload_stub")] + const store = stub(contexts, (input) => + input.directory === dirB ? Effect.die(new Error("sibling reload failed")) : Effect.succeed(contexts[0]!), + ) + + const exit = yield* reloadProject(store, "proj_reload_stub", dirA).pipe(Effect.exit) + + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) +})