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> = {...@@ -261,6 +261,7 @@ export const ClaudeDriver: ProviderDriver<ClaudeSettings, ClaudeDriverEnv> = {
261 accentColor,261 accentColor,
262 enabled,262 enabled,
263 snapshot,263 snapshot,
264 invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache),
264 snapshotForCwd,265 snapshotForCwd,
265 adapter,266 adapter,
266 textGeneration,267 textGeneration,
apps/server/src/provider/Drivers/CursorDriver.ts+3-2
...@@ -134,11 +134,11 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -134,11 +134,11 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
134134
135 const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);135 const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv);
136136
137 const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv);137 const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv);
138 const checkProvider = checkCursorProviderStatus(138 const checkProvider = checkCursorProviderStatus(
139 effectiveConfig,139 effectiveConfig,
140 processEnv,140 processEnv,
141 discoverModels,141 modelDiscovery.discover,
142 ).pipe(142 ).pipe(
143 Effect.flatMap((snapshot) =>143 Effect.flatMap((snapshot) =>
144 effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated"144 effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated"
...@@ -217,6 +217,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {...@@ -217,6 +217,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
217 accentColor,217 accentColor,
218 enabled,218 enabled,
219 snapshot,219 snapshot,
220 invalidateCaches: modelDiscovery.invalidate,
220 snapshotForCwd: (cwd) =>221 snapshotForCwd: (cwd) =>
221 !effectiveConfig.enabled222 !effectiveConfig.enabled
222 ? snapshot.getSnapshot223 ? snapshot.getSnapshot
apps/server/src/provider/Layers/CursorProvider.test.ts+5-1
...@@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => {...@@ -742,7 +742,7 @@ describe("discoverCursorModelsViaAcp", () => {
742 apiEndpoint: "",742 apiEndpoint: "",
743 customModels: [],743 customModels: [],
744 };744 };
745 const discover = yield* makeCursorModelDiscovery(settings, {745 const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, {
746 ...process.env,746 ...process.env,
747 T3_ACP_REQUEST_LOG_PATH: requestLogPath,747 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
748 });748 });
...@@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => {...@@ -755,6 +755,10 @@ describe("discoverCursorModelsViaAcp", () => {
755 yield* fileSystem.writeFileString(requestLogPath, "");755 yield* fileSystem.writeFileString(requestLogPath, "");
756 expect(yield* discover(about)).toEqual(first);756 expect(yield* discover(about)).toEqual(first);
757 expect(yield* fileSystem.readFileString(requestLogPath)).toBe("");757 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, "");
758 yield* discover({ ...about, version: "2026.08.12" });762 yield* discover({ ...about, version: "2026.08.12" });
759 expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize");763 expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize");
760 yield* fileSystem.writeFileString(requestLogPath, "");764 yield* fileSystem.writeFileString(requestLogPath, "");
apps/server/src/provider/Layers/CursorProvider.ts+5-2
...@@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu...@@ -678,8 +678,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu
678 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,678 Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero,
679 },679 },
680 );680 );
681 return (about: Pick<CursorAboutResult, "version" | "auth">) =>681 return {
682 Cache.get(cache, JSON.stringify([about.version, about.auth]));682 discover: (about: Pick<CursorAboutResult, "version" | "auth">) =>
683 Cache.get(cache, JSON.stringify([about.version, about.auth])),
684 invalidate: Cache.invalidateAll(cache),
685 };
683});686});
684687
685function getCursorFallbackModels(688function getCursorFallbackModels(
apps/server/src/provider/ModelManifest.test.ts+79
...@@ -342,6 +342,84 @@ const serviceLayers = (input: {...@@ -342,6 +342,84 @@ const serviceLayers = (input: {
342 );342 );
343343
344describe("ModelManifest service", () => {344describe("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
345 it.live("prefers a fetched manifest over the bundle and caches it to disk", () =>423 it.live("prefers a fetched manifest over the bundle and caches it to disk", () =>
346 Effect.gen(function* () {424 Effect.gen(function* () {
347 const service = yield* make;425 const service = yield* make;
...@@ -457,6 +535,7 @@ describe("ModelManifest service", () => {...@@ -457,6 +535,7 @@ describe("ModelManifest service", () => {
457 ),535 ),
458 );536 );
459 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);537 assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST);
538 assert.deepStrictEqual(yield* service.forceRefresh, BUNDLED_MODEL_MANIFEST);
460 assert.strictEqual(fetchCount, 0);539 assert.strictEqual(fetchCount, 0);
461 }).pipe(540 }).pipe(
462 Effect.scoped,541 Effect.scoped,
apps/server/src/provider/ModelManifest.ts+7-3
...@@ -315,6 +315,8 @@ export class ModelManifest extends Context.Service<...@@ -315,6 +315,8 @@ export class ModelManifest extends Context.Service<
315 readonly current: Effect.Effect<ModelManifestData>;315 readonly current: Effect.Effect<ModelManifestData>;
316 /** Manifest after a TTL-gated remote refresh; never fails. */316 /** Manifest after a TTL-gated remote refresh; never fails. */
317 readonly refresh: Effect.Effect<ModelManifestData>;317 readonly refresh: Effect.Effect<ModelManifestData>;
318 /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */
319 readonly forceRefresh: Effect.Effect<ModelManifestData>;
318 /** Forks `refresh` into the service's own scope. Drivers call this from320 /** Forks `refresh` into the service's own scope. Drivers call this from
319 * provider checks: the fetch is process-shared state, so it must survive321 * provider checks: the fetch is process-shared state, so it must survive
320 * the teardown of whichever instance happened to trigger it. */322 * the teardown of whichever instance happened to trigger it. */
...@@ -326,6 +328,7 @@ export class ModelManifest extends Context.Service<...@@ -326,6 +328,7 @@ export class ModelManifest extends Context.Service<
326const BundledOnlyModelManifest: ModelManifest["Service"] = {328const BundledOnlyModelManifest: ModelManifest["Service"] = {
327 current: Effect.succeed(BUNDLED_MODEL_MANIFEST),329 current: Effect.succeed(BUNDLED_MODEL_MANIFEST),
328 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),330 refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
331 forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST),
329 refreshInBackground: Effect.void,332 refreshInBackground: Effect.void,
330};333};
331334
...@@ -369,7 +372,7 @@ export const make = Effect.gen(function* () {...@@ -369,7 +372,7 @@ export const make = Effect.gen(function* () {
369 }),372 }),
370 );373 );
371374
372 const refresh = Effect.fn("ModelManifest.refresh")(function* () {375 const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) {
373 yield* ensureDiskCacheLoaded;376 yield* ensureDiskCacheLoaded;
374 const now = yield* Clock.currentTimeMillis;377 const now = yield* Clock.currentTimeMillis;
375 // A timestamp in the future means the wall clock moved backwards (the378 // A timestamp in the future means the wall clock moved backwards (the
...@@ -377,8 +380,8 @@ export const make = Effect.gen(function* () {...@@ -377,8 +380,8 @@ export const make = Effect.gen(function* () {
377 // it as expired: the refetch rewrites both timestamps and self-heals.380 // it as expired: the refetch rewrites both timestamps and self-heals.
378 const isWithin = (sinceMs: number | null, windowMs: number) =>381 const isWithin = (sinceMs: number | null, windowMs: number) =>
379 sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;382 sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs;
380 if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;383 if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest;
381 if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;384 if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest;
382385
383 // The same switch that gates provider CLI update checks. It stops network386 // The same switch that gates provider CLI update checks. It stops network
384 // fetches only: a manifest already cached on disk from an earlier fetch387 // fetches only: a manifest already cached on disk from an earlier fetch
...@@ -413,6 +416,7 @@ export const make = Effect.gen(function* () {...@@ -413,6 +416,7 @@ export const make = Effect.gen(function* () {
413 return ModelManifest.of({416 return ModelManifest.of({
414 current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),417 current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)),
415 refresh: guardedRefresh,418 refresh: guardedRefresh,
419 forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)),
416 refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),420 refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid),
417 });421 });
418});422});
apps/server/src/provider/ProviderDriver.ts+2
...@@ -74,6 +74,8 @@ export interface ProviderInstance {...@@ -74,6 +74,8 @@ export interface ProviderInstance {
74 readonly snapshot: ServerProviderShape;74 readonly snapshot: ServerProviderShape;
75 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;75 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
76 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;76 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
77 /** Invalidate T3-owned discovery caches before an explicit provider refresh. */
78 readonly invalidateCaches?: Effect.Effect<void>;
77 /**79 /**
78 * Redeem one banked rate-limit reset credit on the signed-in account, then80 * Redeem one banked rate-limit reset credit on the signed-in account, then
79 * re-probe so the snapshot reflects the cleared windows. Account-level,81 * 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...@@ -132,6 +132,7 @@ import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationE
132import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";132import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEventStore.ts";
133import { PersistenceSqlError } from "./persistence/Errors.ts";133import { PersistenceSqlError } from "./persistence/Errors.ts";
134import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";134import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
135import * as ModelManifest from "./provider/ModelManifest.ts";
135import * as ProviderService from "./provider/Services/ProviderService.ts";136import * as ProviderService from "./provider/Services/ProviderService.ts";
136import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";137import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";
137import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";138import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
...@@ -142,7 +143,10 @@ import {...@@ -142,7 +143,10 @@ import {
142import type { ProviderInstance } from "./provider/ProviderDriver.ts";143import type { ProviderInstance } from "./provider/ProviderDriver.ts";
143import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";144import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
144import { ProviderAdapterRequestError } from "./provider/Errors.ts";145import { ProviderAdapterRequestError } from "./provider/Errors.ts";
145import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";146import {
147 makeManualOnlyProviderMaintenanceCapabilities,
148 ProviderVersionCache,
149} from "./provider/providerMaintenance.ts";
146import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";150import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
147import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";151import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
148import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";152import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
...@@ -516,6 +520,7 @@ const buildAppUnderTest = (options?: {...@@ -516,6 +520,7 @@ const buildAppUnderTest = (options?: {
516 keybindings?: Partial<Keybindings.Keybindings["Service"]>;520 keybindings?: Partial<Keybindings.Keybindings["Service"]>;
517 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;521 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
518 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;522 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
523 modelManifest?: Partial<ModelManifest.ModelManifest["Service"]>;
519 usageLimitSources?: Partial<UsageLimitSources.UsageLimitSources["Service"]>;524 usageLimitSources?: Partial<UsageLimitSources.UsageLimitSources["Service"]>;
520 providerService?: Partial<ProviderService.ProviderService["Service"]>;525 providerService?: Partial<ProviderService.ProviderService["Service"]>;
521 providerAuth?: Partial<ProviderAuthService["Service"]>;526 providerAuth?: Partial<ProviderAuthService["Service"]>;
...@@ -790,6 +795,10 @@ const buildAppUnderTest = (options?: {...@@ -790,6 +795,10 @@ const buildAppUnderTest = (options?: {
790 ),795 ),
791 Layer.provide(796 Layer.provide(
792 Layer.mergeAll(797 Layer.mergeAll(
798 Layer.mock(ModelManifest.ModelManifest)({
799 forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST),
800 ...options?.layers?.modelManifest,
801 }),
793 Layer.mock(ProviderRegistry.ProviderRegistry)({802 Layer.mock(ProviderRegistry.ProviderRegistry)({
794 getProviders: Effect.succeed([]),803 getProviders: Effect.succeed([]),
795 refresh: () => Effect.succeed([]),804 refresh: () => Effect.succeed([]),
...@@ -6337,6 +6346,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -6337,6 +6346,99 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
6337 }).pipe(Effect.provide(NodeHttpServer.layerTest)),6346 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
6338 );6347 );
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
6340 it.effect("serves config on reconnect without starting provider probes", () =>6442 it.effect("serves config on reconnect without starting provider probes", () =>
6341 Effect.gen(function* () {6443 Effect.gen(function* () {
6342 const refresh = vi.fn(() => Effect.never);6444 const refresh = vi.fn(() => Effect.never);
apps/server/src/ws.ts+26
...@@ -109,6 +109,8 @@ import {...@@ -109,6 +109,8 @@ import {
109 observeRpcStreamEffect as instrumentRpcStreamEffect,109 observeRpcStreamEffect as instrumentRpcStreamEffect,
110} from "./observability/RpcInstrumentation.ts";110} from "./observability/RpcInstrumentation.ts";
111import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";111import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
112import * as ModelManifest from "./provider/ModelManifest.ts";
113import * as ProviderMaintenance from "./provider/providerMaintenance.ts";
112import * as ProviderService from "./provider/Services/ProviderService.ts";114import * as ProviderService from "./provider/Services/ProviderService.ts";
113import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";115import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts";
114import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";116import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
...@@ -561,6 +563,8 @@ const makeWsRpcLayer = (...@@ -561,6 +563,8 @@ const makeWsRpcLayer = (
561 yield* Effect.context<Effect.Services<ReturnType<typeof remoteSshDeviceHosts>>>();563 yield* Effect.context<Effect.Services<ReturnType<typeof remoteSshDeviceHosts>>>();
562 const portDiscovery = yield* PortScanner.PortDiscovery;564 const portDiscovery = yield* PortScanner.PortDiscovery;
563 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;565 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
566 const modelManifest = yield* ModelManifest.ModelManifest;
567 const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache;
564 const providerService = yield* ProviderService.ProviderService;568 const providerService = yield* ProviderService.ProviderService;
565 const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;569 const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
566 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;570 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
...@@ -2361,6 +2365,28 @@ const makeWsRpcLayer = (...@@ -2361,6 +2365,28 @@ const makeWsRpcLayer = (
2361 observeRpcEffect(2365 observeRpcEffect(
2362 WS_METHODS.serverRefreshProviders,2366 WS_METHODS.serverRefreshProviders,
2363 Effect.gen(function* () {2367 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 }
2364 // An untargeted refresh is "re-read everything's status", which2390 // An untargeted refresh is "re-read everything's status", which
2365 // includes quota from configured usage-limit sources. Awaited,2391 // includes quota from configured usage-limit sources. Awaited,
2366 // not forked: the RPC scope closes on return and would2392 // 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,...@@ -476,7 +476,8 @@ const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders,
476 */476 */
477 instanceId: Schema.optional(ProviderInstanceId),477 instanceId: Schema.optional(ProviderInstanceId),
478 cwd: Schema.optional(TrimmedNonEmptyString),478 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. */
480 refreshModels: Schema.optional(Schema.Boolean),481 refreshModels: Schema.optional(Schema.Boolean),
481 }),482 }),
482 success: ServerProviderUpdatedPayload,483 success: ServerProviderUpdatedPayload,