Merge pull request #12203 from Kilo-Org/fix-model-cache-race

fix(cli): isolate model cache refresh from caller cancellation
This commit is contained in:
Marius
2026-07-14 15:15:28 +02:00
committed by GitHub
6 changed files with 212 additions and 19 deletions
@@ -0,0 +1,13 @@
import { expect, test } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { isInterrupted, shouldReportPromptFailure } from "../../src/kilocode/effect/cause"
test("recognizes a pure interruption", () => {
const exit = Effect.runSync(Effect.exit(Effect.interrupt))
if (Exit.isSuccess(exit)) throw new Error("expected interruption")
expect(isInterrupted(exit.cause)).toBe(true)
expect(shouldReportPromptFailure(exit.cause)).toBe(false)
expect(isInterrupted(Cause.die(new Error("failure")))).toBe(false)
expect(shouldReportPromptFailure(Cause.die(new Error("failure")))).toBe(true)
expect(shouldReportPromptFailure(Cause.combine(exit.cause, Cause.die(new Error("failure"))))).toBe(true)
})
@@ -1,11 +1,11 @@
// kilocode_change - new file
import { expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer, Option, Ref } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Auth } from "../../src/auth"
import { ModelCache } from "../../src/provider/model-cache"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
type Hit = { readonly url: string }
@@ -19,19 +19,20 @@ function layer(
hits: Ref.Ref<Hit[]>,
cfg = TestConfig.layer(),
access = auth,
gates?: { readonly started: Deferred.Deferred<void>; readonly wait: Deferred.Deferred<void> },
gates?: { readonly started: Deferred.Deferred<void>; readonly wait: Deferred.Deferred<void>; readonly count?: number },
fail?: number,
) {
const http = HttpClient.make((request) =>
Effect.gen(function* () {
yield* Ref.update(hits, (list) => [...list, { url: request.url }])
const count = (yield* Ref.get(hits)).length
if (gates && count === 1) {
if (gates && count === (gates.count ?? 1)) {
yield* Deferred.succeed(gates.started, undefined)
yield* Deferred.await(gates.wait)
}
return HttpClientResponse.fromWeb(
request,
Response.json({ data: [{ id: `apertis-${count}`, owned_by: "apertis" }] }),
Response.json(count === fail ? null : { data: [{ id: `apertis-${count}`, owned_by: "apertis" }] }),
)
}),
)
@@ -76,6 +77,96 @@ it.live("reuses cached values and refresh invalidates the provider cell", () =>
}),
)
it.live("retries after a failed refresh", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const out = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const failed = yield* cache.fetch("apertis", { apiKey: "test-key" }).pipe(Effect.exit)
const models = yield* cache.fetch("apertis", { apiKey: "test-key" })
return { failed, models }
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, undefined, 1)))
expect(Exit.isFailure(out.failed)).toBe(true)
expect(Object.keys(out.models)).toEqual(["apertis-2"])
expect((yield* Ref.get(hits)).length).toBe(2)
}),
)
it.live("keeps a shared refresh alive when one waiter times out", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const out = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const first = yield* cache
.fetch("apertis", { apiKey: "test-key" })
.pipe(Effect.timeoutOption("10 millis"), Effect.forkChild)
yield* Deferred.await(started)
const second = yield* cache.fetch("apertis", { apiKey: "test-key" }).pipe(Effect.forkChild)
expect(Option.isNone(yield* Fiber.join(first))).toBe(true)
yield* Deferred.succeed(wait, undefined)
const models = yield* Fiber.join(second)
return { models, cached: yield* cache.get("apertis") }
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
expect(Object.keys(out.models)).toEqual(["apertis-1"])
expect(out.cached).toEqual(out.models)
expect((yield* Ref.get(hits)).length).toBe(1)
}),
)
it.live("commits a refresh after its only waiter times out", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const cached = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const caller = yield* cache
.fetch("apertis", { apiKey: "test-key" })
.pipe(Effect.timeoutOption("10 millis"), Effect.forkChild)
yield* Deferred.await(started)
expect(Option.isNone(yield* Fiber.join(caller))).toBe(true)
yield* Deferred.succeed(wait, undefined)
return yield* pollWithTimeout(
cache.get("apertis"),
"service-owned refresh did not commit after its waiter timed out",
)
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait })))
expect(Object.keys(cached)).toEqual(["apertis-1"])
expect((yield* Ref.get(hits)).length).toBe(1)
}),
)
it.live("deduplicates overlapping refresh calls", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const out = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
yield* cache.fetch("apertis", { apiKey: "test-key" })
const first = yield* cache.refresh("apertis", { apiKey: "test-key" }).pipe(Effect.forkChild)
yield* Deferred.await(started)
const second = yield* cache.refresh("apertis", { apiKey: "test-key" }).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(wait, undefined)
return { first: yield* Fiber.join(first), second: yield* Fiber.join(second) }
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait, count: 2 })))
expect(Object.keys(out.first)).toEqual(["apertis-2"])
expect(out.second).toEqual(out.first)
expect((yield* Ref.get(hits)).length).toBe(2)
}),
)
it.live("keeps concurrent request options isolated", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
@@ -131,6 +222,34 @@ it.live("does not let an older fetch override a newer refresh", () =>
}),
)
it.live("promotes a cached result after a newer option load fails", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])
const started = yield* Deferred.make<void>()
const wait = yield* Deferred.make<void>()
const out = yield* ModelCache.Service.use((cache) =>
Effect.gen(function* () {
const first = yield* cache
.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
.pipe(Effect.forkChild)
yield* Deferred.await(started)
const failed = yield* cache
.fetch("apertis", { apiKey: "second", baseURL: "https://second.test/v1" })
.pipe(Effect.exit)
yield* Deferred.succeed(wait, undefined)
yield* Fiber.join(first)
const models = yield* cache.fetch("apertis", { apiKey: "first", baseURL: "https://first.test/v1" })
return { failed, models, current: yield* cache.get("apertis") }
}),
).pipe(Effect.provide(layer(hits, TestConfig.layer(), auth, { started, wait }, 2)))
expect(Exit.isFailure(out.failed)).toBe(true)
expect(Object.keys(out.models)).toEqual(["apertis-1"])
expect(out.current).toEqual(out.models)
expect((yield* Ref.get(hits)).length).toBe(2)
}),
)
it.live("does not restore a fetch that was cleared while pending", () =>
Effect.gen(function* () {
const hits = yield* Ref.make<Hit[]>([])