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