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:
Johnny Amancio
2026-07-20 14:58:41 +02:00
parent 2742b573f8
commit 520679a91f
9 changed files with 162 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep the CLI sidebar branch label in sync when Git branches change outside Kilo.
+1 -1
View File
@@ -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,
])
}
+62
View File
@@ -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)))
+11
View File
@@ -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,
),
+1
View File
@@ -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