From 520679a91f320656cb4dbffe74f0a37e521d45c2 Mon Sep 17 00:00:00 2001 From: Johnny Amancio Date: Fri, 17 Jul 2026 13:25:04 +0200 Subject: [PATCH] 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 --- .changeset/live-cli-branch.md | 5 ++ .github/workflows/test.yml | 2 +- packages/opencode/src/kilocode/bootstrap.ts | 6 ++ packages/opencode/src/kilocode/watcher.ts | 62 +++++++++++++++++ .../opencode/src/project/instance-runtime.ts | 4 ++ packages/opencode/test/fixture/fixture.ts | 11 +++ .../kilocode/instance-vcs-watcher.test.ts | 69 +++++++++++++++++++ .../kilocode/tool-registry-indexing.test.ts | 4 +- packages/opencode/test/preload.ts | 1 + 9 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 .changeset/live-cli-branch.md create mode 100644 packages/opencode/src/kilocode/watcher.ts create mode 100644 packages/opencode/test/kilocode/instance-vcs-watcher.test.ts diff --git a/.changeset/live-cli-branch.md b/.changeset/live-cli-branch.md new file mode 100644 index 00000000000..c2d18faa86a --- /dev/null +++ b/.changeset/live-cli-branch.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep the CLI sidebar branch label in sync when Git branches change outside Kilo. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f07f6b4cf9..1575f61ab58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/packages/opencode/src/kilocode/bootstrap.ts b/packages/opencode/src/kilocode/bootstrap.ts index 92c5fcaa58d..fc89546597f 100644 --- a/packages/opencode/src/kilocode/bootstrap.ts +++ b/packages/opencode/src/kilocode/bootstrap.ts @@ -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, ]) } diff --git a/packages/opencode/src/kilocode/watcher.ts b/packages/opencode/src/kilocode/watcher.ts new file mode 100644 index 00000000000..b953762e59e --- /dev/null +++ b/packages/opencode/src/kilocode/watcher.ts @@ -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 + } + + export class Service extends Context.Service()("@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)) + }), + ) +} diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts index c8803847a07..36a4a224424 100644 --- a/packages/opencode/src/project/instance-runtime.ts +++ b/packages/opencode/src/project/instance-runtime.ts @@ -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))) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 6e008eaeeef..78bea2753ba 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -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(options?: TmpDirOptions) { 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(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) }), diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts new file mode 100644 index 00000000000..3bd83f86fed --- /dev/null +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -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() + 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, +) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts index 42f11e7c389..d38f709b1cb 100644 --- a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -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, ), diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 8148df53a13..c89dd27ca6c 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -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