mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
Merge pull request #14103 from Kilo-Org/reload-project-from-worktree
feat(cli): reload the whole project from /reload
This commit is contained in:
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<void> {
|
||||
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. */
|
||||
|
||||
@@ -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<void> =>
|
||||
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 }),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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<SessionID, SessionStatus.Info>):
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface Interface {
|
||||
readonly disposeDirectory: (directory: string) => Effect.Effect<void>
|
||||
readonly disposeAll: () => Effect.Effect<void>
|
||||
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
// kilocode_change start
|
||||
readonly list: () => Effect.Effect<InstanceContext[]>
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
|
||||
@@ -223,11 +226,28 @@ const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Ser
|
||||
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
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<InstanceContext[]> =>
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user