Skip to content

Commit c50d53f

Browse files
committed
refactor(models): tighten Service body per review
- Use Schedule.spaced (not fixed) so the periodic refresh waits between completions instead of firing twice on startup. - Pull JSON.parse out of the Flock-held scope — keeps the cross-process lock release-eager. - Only invalidate after a successful fetchAndWrite; the no-op fresh-skip and ignored-failure paths shouldn't force the next get() to re-read. - Drop comments narrating obvious behavior; fix a misplaced WHY comment. - Use HttpClient.make (matches mockHttpClient elsewhere) instead of makeWith with a Preprocess cast in the new tests.
1 parent 47cd264 commit c50d53f

2 files changed

Lines changed: 16 additions & 23 deletions

File tree

packages/opencode/src/provider/models.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -134,15 +134,13 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClie
134134
Effect.map((v) => v as Record<string, Provider> | undefined),
135135
)
136136

137-
// Bundled snapshot fallback — present in built releases, missing in dev.
137+
// Bundled at build time; absent in dev — `tryPromise` covers both.
138138
const loadSnapshot = Effect.tryPromise({
139139
// @ts-ignore — generated at build time, may not exist in dev
140140
try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record<string, Provider> | undefined),
141141
catch: () => undefined,
142142
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
143143

144-
// Cross-process file lock — Flock is the right primitive (in-process
145-
// semaphores can't coordinate concurrent opencode CLIs writing the same cache).
146144
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
147145
const text = yield* fetchApi()
148146
yield* fs.writeWithDirs(filepath, text)
@@ -155,41 +153,40 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | HttpClie
155153
const snapshot = yield* loadSnapshot
156154
if (snapshot) return snapshot
157155
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
158-
return yield* Effect.scoped(
156+
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
157+
const text = yield* Effect.scoped(
159158
Effect.gen(function* () {
160159
yield* Flock.effect(lockKey)
161-
const text = yield* fetchAndWrite()
162-
return JSON.parse(text) as Record<string, Provider>
160+
return yield* fetchAndWrite()
163161
}),
164162
)
163+
return JSON.parse(text) as Record<string, Provider>
165164
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
166165

167-
// Single-flight cache: concurrent first-call dedupes via the cached effect.
168-
// refresh() invalidates so the next get() re-populates from disk.
169166
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
170167

171168
const get = (): Effect.Effect<Record<string, Provider>> => cachedGet
172169

173170
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
174-
if (!force && (yield* fresh())) return yield* invalidate
171+
if (!force && (yield* fresh())) return
175172
yield* Effect.scoped(
176173
Effect.gen(function* () {
177174
yield* Flock.effect(lockKey)
175+
// Re-check under the lock: another process may have refreshed between
176+
// our outer check and lock acquisition.
178177
if (!force && (yield* fresh())) return
179178
yield* fetchAndWrite()
179+
yield* invalidate
180180
}),
181181
).pipe(
182182
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })),
183183
Effect.ignore,
184184
)
185-
yield* invalidate
186185
})
187186

188187
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
189-
// Eager initial refresh + 1 hour cadence (matches pre-Service behavior).
190-
yield* Effect.forkScoped(
191-
refresh().pipe(Effect.andThen(refresh().pipe(Effect.repeat(Schedule.fixed("60 minutes")))), Effect.ignore),
192-
)
188+
// Schedule.spaced runs the effect once, then waits between completions.
189+
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
193190
}
194191

195192
return Service.of({ get, refresh })

packages/opencode/test/provider/models.test.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, beforeEach, afterAll } from "bun:test"
22
import { Effect, Layer, Ref } from "effect"
3-
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
3+
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
44
import { AppFileSystem } from "@opencode-ai/core/filesystem"
55
import { Flag } from "@opencode-ai/core/flag/flag"
66
import { Global } from "@opencode-ai/core/global"
@@ -64,15 +64,13 @@ interface MockState {
6464
calls: Array<{ url: string }>
6565
}
6666

67-
const makeMockClient = (state: Ref.Ref<MockState>): HttpClient.HttpClient =>
68-
HttpClient.makeWith(
69-
Effect.fnUntraced(function* (requestEffect) {
70-
const request = yield* requestEffect
67+
const makeMockClient = (state: Ref.Ref<MockState>) =>
68+
HttpClient.make((request) =>
69+
Effect.gen(function* () {
7170
yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] }))
7271
const s = yield* Ref.get(state)
7372
return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status }))
7473
}),
75-
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>,
7674
)
7775

7876
const buildLayer = (state: Ref.Ref<MockState>) =>
@@ -244,10 +242,8 @@ describe("ModelsDev Service", () => {
244242
return yield* svc.get()
245243
}),
246244
)
247-
// refresh logged + ignored; the cache map is whatever loadFromDisk yields next.
248-
// With the failing fetch the disk file is unchanged → still the original fixture.
249245
expect(result).toEqual(fixture)
250-
// withTransientReadRetry retries 5xx, so calls > 1 is expected.
246+
// withTransientReadRetry retries 5xx, so calls may be > 1.
251247
const final = yield* Ref.get(state)
252248
expect(final.calls.length).toBeGreaterThanOrEqual(1)
253249
}),

0 commit comments

Comments
 (0)