mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #12314 from Kilo-Org/johnnyeric/fix-issue-12225
fix: keep CLI sidebar branch label in sync with git
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