authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 12:48:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 12:48:00-07:00
logf193a6863c494fdfa36a1ef3132e42a7d7b42926
tree05f033d22f9cc16f11ef4b60cdb97470fc512f44
parent83bbfa7e87e16a3c21413fe55524c1d05ce88dab
signature Signed by PGP key B5690EEEBB952194

fix(server): bypass owned caches on explicit provider refresh (#13109)


10 files changed, 233 insertions(+), 10 deletions(-)

apps/server/src/provider/Drivers/ClaudeDriver.ts+1
......@@ -261,6 +261,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
261261 accentColor,
262262 enabled,
263263 snapshot,
264 invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
264265 snapshotForCwd,
265266 adapter,
266267 textGeneration,
apps/server/src/provider/Drivers/CursorDriver.ts+3-2
......@@ -134,11 +134,11 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
134134
135135 const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);
136136
137 const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv);
137 const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv);
138138 const checkProvider = checkCursorProviderStatus(
139139 effectiveConfig,
140140 processEnv,
141 discoverModels,
141 modelDiscovery.discover,
142142 ).pipe(
143143 Effect.flatMap((snapshot) =>
144144 effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated"
......@@ -217,6 +217,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
217217 accentColor,
218218 enabled,
219219 snapshot,
220 invalidateCaches: modelDiscovery.invalidate,
220221 snapshotForCwd: (cwd) =>
221222 !effectiveConfig.enabled
222223 ? snapshot.getSnapshot
apps/server/src/provider/Layers/CursorProvider.test.ts+5-1
......@@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => {
742742 apiEndpoint: "",
743743 customModels: [],
744744 };
745 const discover = yield* makeCursorModelDiscovery(settings, {
745 const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, {
746746 ...process.env,
747747 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
748748 });
......@@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => {
755755 yield* fileSystem.writeFileString(requestLogPath, "");
756756 expect(yield* discover(about)).toEqual(first);
757757 expect(yield* fileSystem.readFileString(requestLogPath)).toBe("");
758 yield* invalidate;
759 expect(yield* discover(about)).toEqual(first);
760 expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize");
761 yield* fileSystem.writeFileString(requestLogPath, "");
758762 yield* discover({ ...about, version: "2026.08.12" });
759763 expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize");
760764 yield* fileSystem.writeFileString(requestLogPath, "");
apps/server/src/provider/Layers/CursorProvider.ts+5-2
......@@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu
678678 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
679679 },
680680 );
681 return (about: Pick<CursorAboutResult, "version" | "auth">) =>
682 Cache.get(cache, JSON.stringify([about.version, about.auth]));
681 return {
682 discover: (about: Pick<CursorAboutResult, "version" | "auth">) =>
683 Cache.get(cache, JSON.stringify([about.version, about.auth])),
684 invalidate: Cache.invalidateAll(cache),
685 };
683686});
684687
685688function getCursorFallbackModels(
apps/server/src/provider/ModelManifest.test.ts+79
......@@ -342,6 +342,84 @@ const serviceLayers = (input: {
342342 );
343343
344344describe("ModelManifest service", () => {
345 it.live("explicit refresh bypasses fresh memory and disk caches", () => {
346 let fetchCount = 0;
347 const updated: ModelManifestData = {
348 ...REMOTE_MANIFEST,
349 currentModels: { codex: ["gpt-reloaded"] },
350 };
351 return Effect.gen(function* () {
352 const service = yield* make;
353 assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST);
354 assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST);
355 assert.strictEqual(fetchCount, 1);
356
357 const rebooted = yield* make;
358 assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST);
359 assert.strictEqual(fetchCount, 1);
360 assert.deepStrictEqual(yield* rebooted.forceRefresh, updated);
361 assert.strictEqual(fetchCount, 2);
362 assert.deepStrictEqual(yield* rebooted.current, updated);
363 assert.deepStrictEqual(yield* (yield* make).current, updated);
364 }).pipe(
365 Effect.scoped,
366 Effect.provide(
367 serviceLayers({
368 prefix: "model-manifest-force-refresh-test",
369 response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated),
370 }),
371 ),
372 );
373 });
374
375 it.live("explicit refresh retries immediately after failure and preserves last-good data", () => {
376 let fetchCount = 0;
377 return Effect.gen(function* () {
378 const service = yield* make;
379 assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST);
380 assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST);
381 assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST);
382 assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST);
383 assert.strictEqual(fetchCount, 2);
384 assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST);
385 assert.strictEqual(fetchCount, 3);
386 }).pipe(
387 Effect.scoped,
388 Effect.provide(
389 serviceLayers({
390 prefix: "model-manifest-force-retry-test",
391 response: () =>
392 fetchCount++ === 1
393 ? new Response(null, { status: 503 })
394 : Response.json(REMOTE_MANIFEST),
395 }),
396 ),
397 );
398 });
399
400 it.live("explicit refresh bypasses the retry delay after an initial failure", () => {
401 let fetchCount = 0;
402 return Effect.gen(function* () {
403 const service = yield* make;
404 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
405 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
406 assert.strictEqual(fetchCount, 1);
407 assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST);
408 assert.strictEqual(fetchCount, 2);
409 }).pipe(
410 Effect.scoped,
411 Effect.provide(
412 serviceLayers({
413 prefix: "model-manifest-force-initial-retry-test",
414 response: () =>
415 fetchCount++ === 0
416 ? new Response(null, { status: 503 })
417 : Response.json(REMOTE_MANIFEST),
418 }),
419 ),
420 );
421 });
422
345423 it.live("prefers a fetched manifest over the bundle and caches it to disk", () =>
346424 Effect.gen(function* () {
347425 const service = yield* make;
......@@ -457,6 +535,7 @@ describe("ModelManifest service", () => {
457535 ),
458536 );
459537 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
538 assert.deepStrictEqual(yield* service.forceRefresh, BUNDLED_MODEL_MANIFEST);
460539 assert.strictEqual(fetchCount, 0);
461540 }).pipe(
462541 Effect.scoped,
apps/server/src/provider/ModelManifest.ts+7-3
......@@ -315,6 +315,8 @@ export class ModelManifest extends Context.Service<
315315 readonly current: Effect.Effect<ModelManifestData>;
316316 /** Manifest after a TTL-gated remote refresh; never fails. */
317317 readonly refresh: Effect.Effect<ModelManifestData>;
318 /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */
319 readonly forceRefresh: Effect.Effect<ModelManifestData>;
318320 /** Forks `refresh` into the service's own scope. Drivers call this from
319321 * provider checks: the fetch is process-shared state, so it must survive
320322 * the teardown of whichever instance happened to trigger it. */
......@@ -326,6 +328,7 @@ export class ModelManifest extends Context.Service<
326328const BundledOnlyModelManifest: ModelManifest["Service"] = {
327329 current: Effect.succeed(BUNDLED_MODEL_MANIFEST),
328330 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
331 forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
329332 refreshInBackground: Effect.void,
330333};
331334
......@@ -369,7 +372,7 @@ export const make = Effect.gen(function* () {
369372 }),
370373 );
371374
372 const refresh = Effect.fn("ModelManifest.refresh")(function* () {
375 const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) {
373376 yield* ensureDiskCacheLoaded;
374377 const now = yield* Clock.currentTimeMillis;
375378 // A timestamp in the future means the wall clock moved backwards (the
......@@ -377,8 +380,8 @@ export const make = Effect.gen(function* () {
377380 // it as expired: the refetch rewrites both timestamps and self-heals.
378381 const isWithin = (sinceMs: number | null, windowMs: number) =>
379382 sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;
380 if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;
381 if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;
383 if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;
384 if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;
382385
383386 // The same switch that gates provider CLI update checks. It stops network
384387 // fetches only: a manifest already cached on disk from an earlier fetch
......@@ -413,6 +416,7 @@ export const make = Effect.gen(function* () {
413416 return ModelManifest.of({
414417 current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),
415418 refresh: guardedRefresh,
419 forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)),
416420 refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),
417421 });
418422});
apps/server/src/provider/ProviderDriver.ts+2
......@@ -74,6 +74,8 @@ export interface ProviderInstance {
7474 readonly snapshot: ServerProviderShape;
7575 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
7676 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
77 /** Invalidate T3-owned discovery caches before an explicit provider refresh. */
78 readonly invalidateCaches?: Effect.Effect<void>;
7779 /**
7880 * Redeem one banked rate-limit reset credit on the signed-in account, then
7981 * re-probe so the snapshot reflects the cleared windows. Account-level,
apps/server/src/server.test.ts+103-1
......@@ -132,6 +132,7 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE
132132import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
133133import { PersistenceSqlError } from "./persistence/Errors.ts";
134134import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
135import * as ModelManifest from "./provider/ModelManifest.ts";
135136import * as ProviderService from "./provider/Services/ProviderService.ts";
136137import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";
137138import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
......@@ -142,7 +143,10 @@ import {
142143import type { ProviderInstance } from "./provider/ProviderDriver.ts";
143144import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
144145import { ProviderAdapterRequestError } from "./provider/Errors.ts";
145import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
146import {
147 makeManualOnlyProviderMaintenanceCapabilities,
148 ProviderVersionCache,
149} from "./provider/providerMaintenance.ts";
146150import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
147151import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
148152import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
......@@ -516,6 +520,7 @@ const buildAppUnderTest = (options?: {
516520 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
517521 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
518522 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
523 modelManifest?: Partial<ModelManifest.ModelManifest["Service"]>;
519524 usageLimitSources?: Partial<UsageLimitSources.UsageLimitSources["Service"]>;
520525 providerService?: Partial<ProviderService.ProviderService["Service"]>;
521526 providerAuth?: Partial<ProviderAuthService["Service"]>;
......@@ -790,6 +795,10 @@ const buildAppUnderTest = (options?: {
790795 ),
791796 Layer.provide(
792797 Layer.mergeAll(
798 Layer.mock(ModelManifest.ModelManifest)({
799 forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST),
800 ...options?.layers?.modelManifest,
801 }),
793802 Layer.mock(ProviderRegistry.ProviderRegistry)({
794803 getProviders: Effect.succeed([]),
795804 refresh: () => Effect.succeed([]),
......@@ -6337,6 +6346,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
63376346 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
63386347 );
63396348
6349 for (const mode of ["all", "targeted", "background"] as const) {
6350 it.effect(`provider refresh invalidates T3 caches before probing (${mode})`, () => {
6351 const driver = ProviderDriverKind.make("codex");
6352 const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")];
6353 const packageNames = ["@example/personal", "@example/work"];
6354 const versionCache = new Map(
6355 packageNames.map((name) => [
6356 name,
6357 {
6358 expiresAt: Number.MAX_SAFE_INTEGER,
6359 version: "1.0.0",
6360 },
6361 ]),
6362 );
6363 const invalidated: string[] = [];
6364 const freshMaintenance: string[] = [];
6365 let manifestRefreshed = false;
6366 let probed = false;
6367 const instances = instanceIds.map(
6368 (instanceId, index) =>
6369 ({
6370 instanceId,
6371 driverKind: driver,
6372 continuationIdentity: { driverKind: driver, continuationKey: instanceId },
6373 displayName: undefined,
6374 enabled: true,
6375 invalidateCaches: Effect.sync(() => {
6376 invalidated.push(instanceId);
6377 }),
6378 snapshot: {
6379 resolveMaintenance: (options) =>
6380 Effect.sync(() => {
6381 assert.isTrue(options?.fresh);
6382 freshMaintenance.push(instanceId);
6383 return makeManualOnlyProviderMaintenanceCapabilities({
6384 provider: driver,
6385 packageName: packageNames[index]!,
6386 });
6387 }),
6388 getSnapshot: Effect.never,
6389 refresh: Effect.never,
6390 streamChanges: Stream.empty,
6391 applyUsageLimits: () => Effect.void,
6392 },
6393 adapter: {} as ProviderInstance["adapter"],
6394 textGeneration: {} as ProviderInstance["textGeneration"],
6395 }) satisfies ProviderInstance,
6396 );
6397 const expected =
6398 mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds;
6399 const probe = Effect.sync(() => {
6400 probed = true;
6401 assert.equal(manifestRefreshed, mode !== "background");
6402 assert.deepEqual(invalidated.toSorted(), expected.toSorted());
6403 assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted());
6404 for (let index = 0; index < instanceIds.length; index++) {
6405 assert.equal(
6406 versionCache.has(packageNames[index]!),
6407 !expected.includes(instanceIds[index]!),
6408 );
6409 }
6410 return [];
6411 });
6412 return Effect.gen(function* () {
6413 yield* buildAppUnderTest({
6414 layers: {
6415 modelManifest: {
6416 forceRefresh: Effect.sync(() => {
6417 manifestRefreshed = true;
6418 return ModelManifest.BUNDLED_MODEL_MANIFEST;
6419 }),
6420 },
6421 providerInstanceRegistry: { listInstances: Effect.succeed(instances) },
6422 providerRegistry: { refresh: () => probe, refreshInstance: () => probe },
6423 },
6424 });
6425 const wsUrl = yield* getWsServerUrl("/ws");
6426 yield* Effect.scoped(
6427 withWsRpcClient(wsUrl, (client) =>
6428 client[WS_METHODS.serverRefreshProviders]({
6429 ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}),
6430 ...(mode !== "background" ? { refreshModels: true } : {}),
6431 }),
6432 ),
6433 );
6434 assert.isTrue(probed);
6435 }).pipe(
6436 Effect.provideService(ProviderVersionCache, versionCache),
6437 Effect.provide(NodeHttpServer.layerTest),
6438 );
6439 });
6440 }
6441
63406442 it.effect("serves config on reconnect without starting provider probes", () =>
63416443 Effect.gen(function* () {
63426444 const refresh = vi.fn(() => Effect.never);
apps/server/src/ws.ts+26
......@@ -109,6 +109,8 @@ import {
109109 observeRpcStreamEffect as instrumentRpcStreamEffect,
110110} from "./observability/RpcInstrumentation.ts";
111111import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
112import * as ModelManifest from "./provider/ModelManifest.ts";
113import * as ProviderMaintenance from "./provider/providerMaintenance.ts";
112114import * as ProviderService from "./provider/Services/ProviderService.ts";
113115import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
114116import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
......@@ -561,6 +563,8 @@ const makeWsRpcLayer = (
561563 yield* Effect.context<Effect.Services<ReturnType<typeof remoteSshDeviceHosts>>>();
562564 const portDiscovery = yield* PortScanner.PortDiscovery;
563565 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
566 const modelManifest = yield* ModelManifest.ModelManifest;
567 const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache;
564568 const providerService = yield* ProviderService.ProviderService;
565569 const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
566570 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
......@@ -2361,6 +2365,28 @@ const makeWsRpcLayer = (
23612365 observeRpcEffect(
23622366 WS_METHODS.serverRefreshProviders,
23632367 Effect.gen(function* () {
2368 // Only explicit catalog refreshes bypass T3's caches. Workspace
2369 // discovery and background status checks retain their timers.
2370 if (input.refreshModels) {
2371 yield* modelManifest.forceRefresh;
2372 const instances = yield* providerInstances.listInstances;
2373 yield* Effect.forEach(
2374 instances.filter(
2375 (instance) =>
2376 input.instanceId === undefined || input.instanceId === instance.instanceId,
2377 ),
2378 (instance) =>
2379 Effect.gen(function* () {
2380 yield* instance.invalidateCaches ?? Effect.void;
2381 const maintenance = yield* instance.snapshot.resolveMaintenance({
2382 fresh: true,
2383 });
2384 if (maintenance.packageName)
2385 providerVersionCache.delete(maintenance.packageName);
2386 }),
2387 { concurrency: "unbounded", discard: true },
2388 );
2389 }
23642390 // An untargeted refresh is "re-read everything's status", which
23652391 // includes quota from configured usage-limit sources. Awaited,
23662392 // not forked: the RPC scope closes on return and would
packages/contracts/src/rpc.ts+2-1
......@@ -476,7 +476,8 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders,
476476 */
477477 instanceId: Schema.optional(ProviderInstanceId),
478478 cwd: Schema.optional(TrimmedNonEmptyString),
479 /** Explicit user request. Background status refreshes must not open agent sessions. */
479 /** Explicit user request: bypass T3-owned caches and rediscover models.
480 * Background status refreshes must not open agent sessions. */
480481 refreshModels: Schema.optional(Schema.Boolean),
481482 }),
482483 success: ServerProviderUpdatedPayload,