diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b0d7ec6d3ba6..a5405c0396be 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -7,17 +7,18 @@ * * Unlike Codex, the Claude snapshot probe may invoke a secondary probe * (`probeClaudeCapabilities`) to read Anthropic account + slash-command - * metadata. That probe is per-instance and keyed by binary + resolved HOME so - * two concurrent Claude instances don't cross-contaminate account metadata. + * metadata. That probe goes through the server-wide `ClaudeProbeCache`, keyed + * on the full probe input, so instances with the same probe input share one + * probe and instances on different homes never see each other's account. * * @module provider/Drivers/ClaudeDriver */ import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; -import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; @@ -33,11 +34,7 @@ import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; import { makeClaudeScopedLimitNames } from "../Layers/claudeUsageLimits.ts"; import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; -import { - checkClaudeProviderStatus, - makePendingClaudeProvider, - probeClaudeCapabilities, -} from "../Layers/ClaudeProvider.ts"; +import { checkClaudeProviderStatus, makePendingClaudeProvider } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -61,16 +58,12 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { - makeClaudeCapabilitiesCacheKey, - makeClaudeContinuationGroupKey, - resolveClaudeHomePath, -} from "./ClaudeHome.ts"; +import { makeClaudeContinuationGroupKey, resolveClaudeHomePath } from "./ClaudeHome.ts"; +import * as ClaudeProbeCache from "./ClaudeProbeCache.ts"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); -const CAPABILITIES_PROBE_TTL = Duration.minutes(5); function isClaudeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); @@ -93,6 +86,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner + | ClaudeProbeCache.ClaudeProbeCache | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem @@ -119,6 +113,7 @@ export const ClaudeDriver: ProviderDriver = { const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const probeCache = yield* ClaudeProbeCache.ClaudeProbeCache; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const modelManifest = yield* ModelManifest.ModelManifest; @@ -178,21 +173,19 @@ export const ClaudeDriver: ProviderDriver = { modelCatalog, ); - // Per-instance capabilities cache: keyed on binary + resolved HOME so - // account-specific probes never share auth metadata across instances. - const capabilitiesProbeCache = yield* Cache.make({ - capacity: 1, - timeToLive: CAPABILITIES_PROBE_TTL, - lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( - Effect.provideService(Path.Path, path), - ), - }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey( - effectiveConfig, + // Shared with every instance that has the same probe input. + const probeInput = { + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, cwd, - processEnv, - ); + environment, + } satisfies ClaudeProbeCache.ClaudeProbeInput; + // A new or rebuilt instance probes fresh, so a config edit never shows + // an older result. An in-flight probe is joined instead, so instances + // that start together at boot still run one probe. + if (Option.isSome(yield* Cache.getSuccess(probeCache, probeInput))) { + yield* Cache.invalidate(probeCache, probeInput); + } // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. @@ -202,7 +195,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.flatMap((manifest) => checkClaudeProviderStatus( effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + () => Cache.get(probeCache, probeInput), processEnv, cwd, resolveClaudeModelCatalog(manifest), @@ -312,7 +305,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.tap((outcome) => Effect.gen(function* () { const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - yield* Cache.invalidateAll(capabilitiesProbeCache); + yield* Cache.invalidate(probeCache, probeInput); const refreshed = yield* snapshot.refresh; const after = refreshed.usageLimits?.checkedAt; if ( @@ -343,7 +336,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, - invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), + invalidateCaches: Cache.invalidate(probeCache, probeInput), snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index fb1caf753f86..bdfefa3fc504 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -7,7 +7,6 @@ import * as Path from "effect/Path"; import { claudeSignedOutMessage, - makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, resolveClaudeHomePath, @@ -32,7 +31,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); - it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () => + it.effect("resolves configured Claude HOME and stamps continuation keys with it", () => Effect.gen(function* () { const path = yield* Path.Path; const homePath = "~/.claude-work"; @@ -41,9 +40,6 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved); expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved); expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`); - expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${resolved}\0`, - ); }), ); @@ -75,14 +71,5 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { expect(message).not.toContain("CLAUDE_CONFIG_DIR="); expect(message).toContain("then start a new thread"); }); - - it.effect("separates capability probes by cwd", () => - Effect.gen(function* () { - const config = { binaryPath: "claude", homePath: "" }; - const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); - const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); - expect(first).not.toBe(second); - }), - ); }); }); diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 70699ca669e8..02fb86b2f0ba 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -63,17 +63,6 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG }, ); -export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( - function* ( - config: Pick, - cwd?: string, - environment?: NodeJS.ProcessEnv, - ): Effect.fn.Return { - const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); - return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; - }, -); - /** * Describe the spawned CLI's environment separately from the login command so * paths remain literal on every shell, including relative inherited values. diff --git a/apps/server/src/provider/Drivers/ClaudeProbeCache.ts b/apps/server/src/provider/Drivers/ClaudeProbeCache.ts new file mode 100644 index 000000000000..a2d3837e60bf --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeProbeCache.ts @@ -0,0 +1,43 @@ +/** + * One server-wide cache for the Claude capabilities probe. Claude instances + * with the same probe input read the same account, so they share one cached + * result, and concurrent reads of one input join one SDK probe. Each entry + * keeps 5 minutes, a failed probe (`undefined`) included. + * + * @module provider/Drivers/ClaudeProbeCache + */ +import type { ClaudeSettings, ProviderInstanceEnvironment } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Layer from "effect/Layer"; + +import { type ClaudeCapabilitiesProbe, probeClaudeCapabilities } from "../Layers/ClaudeProvider.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +/** + * Every instance input the probe reads, and also the cache key. The lookup + * gets only this value, so the probe cannot read an input the key leaves out. + * Keys compare structurally. + */ +export type ClaudeProbeInput = Pick & { + readonly cwd: string; + readonly environment: ProviderInstanceEnvironment; +}; + +export class ClaudeProbeCache extends Context.Service< + ClaudeProbeCache, + Cache.Cache +>()("t3/provider/Drivers/ClaudeProbeCache") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Cache.make({ + // Far above any real input count. Each refresh reads keys in the same + // order, so a cap below the live key count would re-probe every key. + capacity: 256, + timeToLive: Duration.minutes(5), + lookup: (input: ClaudeProbeInput) => + probeClaudeCapabilities(input, mergeProviderInstanceEnvironment(input.environment), input.cwd), +}); + +export const layer = Layer.effect(ClaudeProbeCache, make); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index db06557c7c8e..b296a9fd1cda 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -225,7 +225,7 @@ function nonEmptyProbeString(value: string): string | undefined { return candidate ? candidate : undefined; } -type ClaudeCapabilitiesProbe = { +export type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; @@ -330,7 +330,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * subscription type information. */ const probeClaudeCapabilities = ( - claudeSettings: ClaudeSettings, + claudeSettings: Pick, environment?: NodeJS.ProcessEnv, cwd?: string, ) => { diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index c33b1dfc690a..e284545253c5 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -22,6 +22,7 @@ * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ +import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -32,6 +33,7 @@ import { type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, + type ProviderInstanceEnvironment, ProviderInstanceId, } from "@t3tools/contracts"; import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; @@ -42,6 +44,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; @@ -50,6 +53,7 @@ import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; +import * as ClaudeProbeCache from "../Drivers/ClaudeProbeCache.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; @@ -60,6 +64,8 @@ import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; +vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); + const TestHttpClientLive = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -245,6 +251,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { const testLayer = ServerConfig.layerTest(process.cwd(), { prefix: "provider-instance-registry-test", }).pipe( + Layer.provideMerge(ClaudeProbeCache.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), @@ -486,33 +493,40 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }), ); const instanceId = ProviderInstanceId.make("claude_reset"); + // A sibling with the same probe input shares the cached probe. + const siblingId = ProviderInstanceId.make("claude_reset_sibling"); + const entry = { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + environment: [ + { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, + ...(claim.usageFailsAfterClaim + ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] + : []), + ], + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }; const { registry } = yield* makeProviderInstanceRegistry({ drivers: [ClaudeDriver], - configMap: { - [instanceId]: { - driver: ProviderDriverKind.make("claudeAgent"), - enabled: true, - environment: [ - { name: "T3_CLAUDE_RESET_MARKER", value: marker, sensitive: false }, - ...(claim.usageFailsAfterClaim - ? [{ name: "T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM", value: "1", sensitive: false }] - : []), - ], - config: makeClaudeConfig({ - enabled: true, - binaryPath: fixtures.claudeBinaryPath, - homePath: fixtures.claudeHomePath, - }), - }, - }, + configMap: { [instanceId]: entry, [siblingId]: entry }, }).pipe(Effect.provideService(HttpClient.HttpClient, client)); const instance = yield* registry.getInstance(instanceId); + const sibling = yield* registry.getInstance(siblingId); expect(instance).toBeDefined(); + expect(sibling).toBeDefined(); const before = yield* instance!.snapshot.refresh; expect(before.usageLimits?.windows[0]?.usedPercent).toBe(100); expect(before.usageLimits?.resetCredits?.nextCreditId).toBe("grant_a"); const outcome = yield* instance!.consumeResetCredit!().pipe(Effect.result); - return { outcome, after: yield* instance!.snapshot.getSnapshot }; + return { + outcome, + after: yield* instance!.snapshot.getSnapshot, + siblingAfter: yield* sibling!.snapshot.refresh, + }; }).pipe( // macOS logins live in the Keychain, where resets are never read. Effect.provideService(HostProcessPlatform, "linux"), @@ -530,6 +544,16 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }), ); + it.live("a Claude reset re-probes the usage that siblings share", () => + Effect.gen(function* () { + const { siblingAfter } = yield* redeemClaudeReset({ + result: "reset", + usageFailsAfterClaim: false, + }); + expect(siblingAfter.usageLimits?.windows[0]?.usedPercent).toBe(0); + }), + ); + it.live("reports Claude's answer when a claim changed nothing and the re-probe fails", () => Effect.gen(function* () { const { outcome } = yield* redeemClaudeReset({ @@ -540,6 +564,87 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }), ); + // Instances with the same binary, home, cwd and env vars read the same + // account, so they share one SDK probe. An explicit refresh or a rebuild + // re-probes it. + it.live("shares one Claude probe between instances with the same probe input", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fixtures = yield* makeTildeProviderFixtures(); + const firstProbeStarted = Promise.withResolvers(); + const probesMayFinish = Promise.withResolvers(); + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(() => { + firstProbeStarted.resolve(); + return { + initializationResult: async () => { + await probesMayFinish.promise; + return {}; + }, + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: false, + rate_limits: null, + }), + } as ReturnType; + }); + // The module mock records every earlier test's real probes too. + query.mockClear(); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const probedAccounts = () => + query.mock.calls + .map(([params]) => params.options?.env?.T3_TEST_ACCOUNT ?? "default") + .toSorted(); + const claude = (displayName: string, environment: ProviderInstanceEnvironment = []) => ({ + driver: ProviderDriverKind.make("claudeAgent"), + displayName, + enabled: true, + environment, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }); + const claudeAId = ProviderInstanceId.make("claude_a"); + const bootConfigMap: ProviderInstanceConfigMap = { [claudeAId]: claude("A") }; + const configMap: ProviderInstanceConfigMap = { + ...bootConfigMap, + [ProviderInstanceId.make("claude_b")]: claude("B"), + [ProviderInstanceId.make("claude_other")]: claude("Other", [ + { name: "T3_TEST_ACCOUNT", value: "other", sensitive: false }, + ]), + }; + const { registry, mutator } = yield* makeProviderInstanceRegistry({ + drivers: [ClaudeDriver], + configMap: bootConfigMap, + }); + const refreshAll = registry.listInstances.pipe( + Effect.flatMap((instances) => + Effect.forEach(instances, (instance) => instance.snapshot.refresh, { + concurrency: "unbounded", + }), + ), + ); + // Like boot: the other instances start while claude_a's probe is in flight. + yield* Effect.promise(() => firstProbeStarted.promise); + yield* mutator.reconcile(configMap); + probesMayFinish.resolve(); + yield* refreshAll; + expect(probedAccounts()).toEqual(["default", "other"]); + + const claudeA = yield* registry.getInstance(claudeAId); + yield* claudeA?.invalidateCaches ?? Effect.void; + yield* refreshAll; + expect(probedAccounts()).toEqual(["default", "default", "other"]); + + // A config edit rebuilds claude_a after its probe finished. + yield* mutator.reconcile({ ...configMap, [claudeAId]: claude("A renamed") }); + const rebuiltA = yield* registry.getInstance(claudeAId); + expect(rebuiltA).not.toBe(claudeA); + yield* rebuiltA?.snapshot.refresh ?? Effect.void; + expect(probedAccounts()).toEqual(["default", "default", "default", "other"]); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => @@ -601,6 +706,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { prefix: "provider-instance-registry-all-drivers-test", }), ), + Layer.provideMerge(ClaudeProbeCache.layer), Layer.provideMerge(infraLayer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge(ServerSettingsService.layerTest()), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ee1a23573277..abc2cd42fdb7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -40,6 +40,7 @@ import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { applyProviderCompatibility } from "../providerCompatibility.ts"; import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; +import * as ClaudeProbeCache from "../Drivers/ClaudeProbeCache.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -2322,6 +2323,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge(ClaudeProbeCache.layer), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -2421,6 +2423,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge(ClaudeProbeCache.layer), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -2537,6 +2540,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge(ClaudeProbeCache.layer), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -2599,6 +2603,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(ResetCreditCoordinator.layerTest), + Layer.provideMerge(ClaudeProbeCache.layer), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 059a2508e40f..6f77a047d509 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -131,7 +131,10 @@ export interface ProviderDriverCreateInput { * `create` is responsible for *all* per-instance state — process handles, * pubsub topics, refs, file watchers — and must release them when its * scope closes. Two calls to `create` with different `instanceId` / - * `config` MUST yield instances with no shared mutable state. + * `config` MUST yield instances with no shared mutable state. State that + * belongs to an account rather than an instance lives in a server-wide + * service from `R` instead (see `ResetCreditCoordinator`, `ClaudeProbeCache`), + * keyed so instances share it only when their inputs match. */ export interface ProviderDriver { readonly driverKind: ProviderDriverKind; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 88c08fd3bdee..c72a91a0c8c9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -52,6 +52,7 @@ import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; +import * as ClaudeProbeCache from "./provider/Drivers/ClaudeProbeCache.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; @@ -542,7 +543,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // from the repo's `model-manifest.json` on `main` and applied by the // Codex/Claude drivers. Layer.provideMerge( - Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), + Layer.mergeAll( + ProviderEventLoggers.layer, + ModelManifest.layer, + ResetCreditCoordinator.layer, + ClaudeProbeCache.layer, + ), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but