mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-24 01:24:18 +08:00
fix: keep CLI sidebar branch label in sync with git
The CLI never builds the v2 location stack, so the .git watcher that feeds Vcs branch updates never starts and the sidebar branch label goes stale after a git switch outside Kilo. Warm the LocationServiceMap entry for the instance directory during kilocode bootstrap. This starts the same watcher the file/pty handlers use, keyed by the same ref, so the stack is shared instead of duplicated. The warm-up is forked off the bootstrap critical path and failures only log a warning. Fixes #12225
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep the CLI sidebar branch label in sync when Git branches change outside Kilo.
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
if: matrix.settings.run
|
||||
run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='@kilocode/cli'
|
||||
env:
|
||||
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - was Windows-only; the CLI now starts a watcher per instance, too heavy/racy for unit tests. Watcher tests opt back in.
|
||||
KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }}
|
||||
KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }}
|
||||
# kilocode_change end
|
||||
|
||||
@@ -19,6 +19,7 @@ import { MemoryEvents } from "@/kilocode/memory/events"
|
||||
import { installMemoryRuntime } from "@/kilocode/memory/runtime"
|
||||
import { KiloToolRegistry } from "@/kilocode/tool/registry"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { KilocodeWatcher } from "@/kilocode/watcher"
|
||||
|
||||
const log = Log.create({ service: "kilocode-bootstrap" })
|
||||
|
||||
@@ -40,8 +41,10 @@ export namespace KilocodeBootstrap {
|
||||
const summary = yield* SessionSummary.Service
|
||||
const provider = yield* Provider.Service
|
||||
const memory = yield* MemoryService.Service
|
||||
const watcher = yield* KilocodeWatcher.Service
|
||||
|
||||
const init = Effect.fn("KilocodeBootstrap.init")(function* () {
|
||||
yield* watcher.init()
|
||||
yield* kilo.init()
|
||||
yield* MemoryLifecycle.subscribe({ bus, sessions, summary, provider, memory })
|
||||
// Invalidate enabled cache on every memory state mutation (properties.directory holds the memory root).
|
||||
@@ -99,10 +102,12 @@ export namespace KilocodeBootstrap {
|
||||
Provider.defaultLayer,
|
||||
MemoryService.layer,
|
||||
Bus.defaultLayer,
|
||||
KilocodeWatcher.defaultLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
const memory = LayerNode.make(MemoryService.layer, [])
|
||||
const watcher = LayerNode.make(KilocodeWatcher.defaultLayer, [])
|
||||
export const node = LayerNode.make(layer, [
|
||||
KiloSessions.node,
|
||||
Session.node,
|
||||
@@ -110,5 +115,6 @@ export namespace KilocodeBootstrap {
|
||||
Provider.node,
|
||||
memory,
|
||||
Bus.node,
|
||||
watcher,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Cause, Context, Effect, Layer, Scope } from "effect"
|
||||
|
||||
const log = Log.create({ service: "kilocode-watcher" })
|
||||
|
||||
export namespace KilocodeWatcher {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/Watcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("KilocodeWatcher.state")(function* (ctx) {
|
||||
if (ctx.project.vcs !== "git") return
|
||||
// Warm the v2 location stack for this instance and hold it for the
|
||||
// instance lifetime. Its Watcher subscribes to .git so Vcs sees HEAD
|
||||
// changes and publishes vcs.branch.updated in the CLI, where no v2
|
||||
// route would otherwise build the stack. The ref must be built the
|
||||
// same way the file/pty handlers build theirs (Location.Ref.make) so
|
||||
// the LayerMap shares a single build per directory.
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })
|
||||
yield* locations.contextEffect(ref)
|
||||
// Tear the stack down with the instance instead of letting it idle
|
||||
// in the LayerMap; same pattern as the pty handlers' disposer.
|
||||
yield* Effect.addFinalizer(() => locations.invalidate(ref).pipe(Effect.ignore))
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("KilocodeWatcher.init")(function* () {
|
||||
yield* InstanceState.get(state).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => log.warn("instance watcher init failed", { err: Cause.squash(cause) })),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// Gate the whole layer so LocationServiceMap's dependency graph is never built when the watcher is disabled.
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false)))
|
||||
return Layer.succeed(Service, Service.of({ init: () => Effect.void }))
|
||||
return layer.pipe(Layer.provide(LocationServiceMap.layer))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Se
|
||||
export const disposeInstance = (ctx: InstanceContext) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
|
||||
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
|
||||
// kilocode_change start - test fixtures dispose a directory's instance before deleting the directory
|
||||
export const disposeDirectory = (directory: string) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeDirectory(directory)))
|
||||
// kilocode_change end
|
||||
export const reloadInstance = (input: LoadInput) =>
|
||||
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
|
||||
|
||||
|
||||
@@ -68,6 +68,15 @@ export async function disposeAllInstances() {
|
||||
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
|
||||
}
|
||||
|
||||
// kilocode_change start - dispose a directory's instance (and its watchers) before the directory is deleted
|
||||
async function disposeInstancesFor(directory: string) {
|
||||
await Promise.allSettled([
|
||||
InstanceRuntime.disposeDirectory(directory),
|
||||
runTestInstanceStore((store) => store.disposeDirectory(directory)),
|
||||
])
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||
function sanitizePath(p: string): string {
|
||||
return p.replace(/\0/g, "")
|
||||
@@ -122,6 +131,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
||||
try {
|
||||
await options?.dispose?.(realpath)
|
||||
} finally {
|
||||
await disposeInstancesFor(realpath) // kilocode_change - see disposeInstancesFor
|
||||
if (options?.git) await stop(realpath).catch(() => undefined)
|
||||
await clean(realpath).catch(() => undefined)
|
||||
}
|
||||
@@ -146,6 +156,7 @@ export function tmpdirScoped<E = never, R = never>(options?: {
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await disposeInstancesFor(dir) // kilocode_change - see disposeInstancesFor
|
||||
if (options?.git) await stop(dir).catch(() => undefined)
|
||||
await clean(dir).catch(() => undefined)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterAll, beforeAll, expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Git } from "../../src/git"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
|
||||
const layer = Layer.mergeAll(InstanceLayer.layer, Git.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const it = testEffect(layer)
|
||||
|
||||
// The suite disables the file watcher (see test/preload.ts); this file tests it, so opt back in.
|
||||
const disableFilewatcher = process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||
beforeAll(() => {
|
||||
delete process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||
})
|
||||
afterAll(() => {
|
||||
if (disableFilewatcher !== undefined) process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = disableFilewatcher
|
||||
})
|
||||
|
||||
// The watcher is unreliable on Windows CI, so this test only runs on unix.
|
||||
const live = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
live("instances publish branch updates after git switch", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const git = yield* Git.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
const current = yield* git.branch(dir)
|
||||
if (!current) return yield* Effect.die("missing initial branch")
|
||||
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
const created = yield* git.run(["branch", branch], { cwd: dir })
|
||||
expect(created.exitCode).toBe(0)
|
||||
yield* store.load({ directory: dir })
|
||||
|
||||
const pending = yield* Deferred.make<string | undefined>()
|
||||
const handler = (event: GlobalEvent) => {
|
||||
if (event.directory !== dir || event.payload.type !== "vcs.branch.updated") return
|
||||
if (event.payload.properties.branch !== branch) return
|
||||
Deferred.doneUnsafe(pending, Effect.succeed(event.payload.properties.branch))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", handler)))
|
||||
|
||||
// The watcher exposes no readiness signal (its .git subscription is forked
|
||||
// during instance warm-up), so keep generating HEAD churn in the background
|
||||
// and synchronize on the event itself with the full test budget.
|
||||
const churn = yield* Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* git.run(["switch", current], { cwd: dir })
|
||||
yield* Effect.sleep("50 millis")
|
||||
yield* git.run(["switch", branch], { cwd: dir })
|
||||
yield* Effect.sleep("100 millis")
|
||||
}
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const updated = yield* awaitWithTimeout(
|
||||
Deferred.await(pending),
|
||||
"timed out waiting for vcs.branch.updated",
|
||||
"15 seconds",
|
||||
)
|
||||
yield* Fiber.interrupt(churn)
|
||||
expect(updated).toBe(branch)
|
||||
}),
|
||||
20_000,
|
||||
)
|
||||
@@ -5,6 +5,7 @@ import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { KiloIndexing } from "../../src/kilocode/indexing"
|
||||
import { KilocodeBootstrap } from "../../src/kilocode/bootstrap"
|
||||
import { KilocodeWatcher } from "../../src/kilocode/watcher"
|
||||
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
|
||||
import { KiloMemory } from "@kilocode/kilo-memory/effect"
|
||||
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
|
||||
@@ -471,6 +472,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
const session = Layer.succeed(Session.Service, {} as Session.Interface)
|
||||
const summary = Layer.succeed(SessionSummary.Service, {} as SessionSummary.Interface)
|
||||
const provider = Layer.succeed(Provider.Service, {} as Provider.Interface)
|
||||
const watcher = Layer.succeed(KilocodeWatcher.Service, KilocodeWatcher.Service.of({ init: () => Effect.void }))
|
||||
const indexing = spyOn(KiloIndexing, "init").mockRejectedValue(err)
|
||||
const warn = spyOn(logger, "warn").mockImplementation(() => {})
|
||||
|
||||
@@ -478,7 +480,7 @@ describe("kilocode tool registry indexing", () => {
|
||||
await Effect.runPromise(
|
||||
KilocodeBootstrap.Service.use((svc) => svc.init()).pipe(
|
||||
Effect.provide(
|
||||
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider])),
|
||||
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider, watcher])),
|
||||
),
|
||||
Effect.scoped,
|
||||
),
|
||||
|
||||
@@ -40,6 +40,7 @@ process.env["XDG_STATE_HOME"] = path.join(dir, "state")
|
||||
process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
|
||||
process.env["KILO_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
|
||||
process.env["KILO_EXPERIMENTAL_WORKSPACES"] = "true"
|
||||
process.env["KILO_EXPERIMENTAL_DISABLE_FILEWATCHER"] ??= "true" // kilocode_change - see test.yml: per-instance watchers are too heavy/racy for unit tests; watcher tests opt back in
|
||||
|
||||
// Set test home directory to isolate tests from user's actual home directory
|
||||
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
|
||||
|
||||
Reference in New Issue
Block a user