diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b0d7ec6d3ba6..46efa8972de0 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -13,8 +13,6 @@ * @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"; @@ -35,8 +33,8 @@ import * as ClaudeResetCredits from "../Layers/claudeResetCredits.ts"; import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkClaudeProviderStatus, + makeClaudeCapabilitiesCache, makePendingClaudeProvider, - probeClaudeCapabilities, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; @@ -61,16 +59,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { - makeClaudeCapabilitiesCacheKey, - makeClaudeContinuationGroupKey, - resolveClaudeHomePath, -} from "./ClaudeHome.ts"; +import { makeClaudeContinuationGroupKey, resolveClaudeHomePath } from "./ClaudeHome.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); @@ -178,21 +171,11 @@ 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( + const capabilities = yield* makeClaudeCapabilitiesCache( effectiveConfig, - cwd, processEnv, - ); + cwd, + ).pipe(Effect.provideService(Path.Path, path)); // Start the TTL-gated refresh without delaying provider readiness. The // next check observes a remote manifest after the background fetch lands. @@ -202,7 +185,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.flatMap((manifest) => checkClaudeProviderStatus( effectiveConfig, - () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), + () => capabilities.get, processEnv, cwd, resolveClaudeModelCatalog(manifest), @@ -312,7 +295,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.tap((outcome) => Effect.gen(function* () { const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; - yield* Cache.invalidateAll(capabilitiesProbeCache); + yield* capabilities.invalidate; const refreshed = yield* snapshot.refresh; const after = refreshed.usageLimits?.checkedAt; if ( @@ -343,7 +326,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, - invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), + invalidateCaches: capabilities.invalidate, snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 232b8cc02d00..1eab933c531c 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + makeClaudeCapabilitiesCache, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; @@ -223,3 +224,65 @@ it.effect("preserves initialized capabilities when optional usage times out", () assert.equal(abortSignal?.aborted, true); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("answers one failed probe with the last good account, and never caches a failure", () => + Effect.gen(function* () { + let initialization: "fails" | "succeeds" = "fails"; + const query = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + initializationResult: async () => { + if (initialization === "fails") { + throw new Error("Claude Code process exited with code 1"); + } + return { + account: { + email: "dev@example.com", + subscriptionType: "pro", + tokenSource: "oauth", + }, + commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }], + }; + }, + usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: async () => ({ + rate_limits_available: true, + rate_limits: {}, + }), + }) as unknown as ReturnType, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); + const capabilities = yield* makeClaudeCapabilitiesCache( + decodeClaudeSettings({ binaryPath: "claude" }), + ); + + // Nothing good to fall back on yet, so the caller sees the failure. + const firstFailure = yield* Effect.flip(capabilities.get); + assert.equal(firstFailure._tag, "UnknownError"); + + initialization = "succeeds"; + const good = yield* capabilities.get; + assert.equal(good.email, "dev@example.com"); + assert.equal(good.usage?.rate_limits_available, true); + yield* capabilities.get; + assert.equal(query.mock.calls.length, 2); + + initialization = "fails"; + yield* TestClock.adjust("5 minutes"); + const lastGood = yield* capabilities.get; + assert.equal(lastGood.email, "dev@example.com"); + assert.equal(lastGood.subscriptionType, "pro"); + assert.deepEqual(lastGood.slashCommands, [ + { name: "review", description: "Review changes", input: { hint: "[path]" } }, + ]); + // Five-minute-old usage windows would be republished as current. + assert.equal(lastGood.usage, undefined); + yield* capabilities.get; + assert.equal(query.mock.calls.length, 3); + + // A second failure in a row reaches the caller, and the next check probes again. + yield* TestClock.adjust("5 minutes"); + assert.equal((yield* Effect.flip(capabilities.get))._tag, "UnknownError"); + yield* Effect.flip(capabilities.get); + assert.equal(query.mock.calls.length, 5); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index db06557c7c8e..ea88c2f839d1 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,8 +4,12 @@ import { type ServerProviderSlashCommand, type ServerProviderResetCredits, } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -34,7 +38,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeCapabilitiesCacheKey, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { @@ -168,9 +172,10 @@ function apiProviderAuthMetadata( // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the // Bedrock backend and runs the `awsAuthRefresh` credential hook before returning -// account info. The previous 8s budget expired mid-init, so the probe returned -// `undefined` and left the provider unverified and unselectable in the picker. +// account info. The previous 8s budget expired mid-init, so the probe timed out +// and left the provider unverified and unselectable in the picker. const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000; +const CAPABILITIES_PROBE_TTL = Duration.minutes(5); /** * Keep workspace-scoped command discovery intact while isolating the periodic @@ -237,9 +242,10 @@ type ClaudeCapabilitiesProbe = { readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; /** - * Subscription windows from the SDK's `get_usage` control request, or - * `undefined` when the request itself failed. Absent windows on an - * otherwise successful response mean the account has none (API key). + * Subscription windows from the SDK's `get_usage` control request. + * `undefined` when that request failed, or when the last good probe answers + * for a failed one. Absent windows on an otherwise successful response mean + * the account has none (API key). */ readonly usage?: Pick; }; @@ -326,8 +332,8 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * account info and slash commands) but never starts an API request to * Anthropic. We read the init data and then abort the subprocess. * - * This is used as a fallback when `claude auth status` does not include - * subscription type information. + * Fails with the timeout or SDK error. `makeClaudeCapabilitiesCache` decides + * what a failed probe means for the provider snapshot. */ const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, @@ -396,11 +402,58 @@ const probeClaudeCapabilities = ( if (!abort.signal.aborted) abort.abort(); }), ), - Effect.result, - Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)), + // Log only the tag. SDK errors can quote the CLI's stderr. + Effect.tapError((error) => + Effect.logWarning("Claude capability probe failed.", { errorTag: error._tag }), + ), ); }; +type ClaudeCapabilitiesProbeError = Effect.Error>; + +/** + * One Claude instance's capability probe, as `checkClaudeProviderStatus` reads + * it. Every answer is reused for five minutes. One stalled probe must not turn + * a working Claude into an auth warning, so when a probe fails, the last good + * probe answers once more, without its usage windows. Those would otherwise be + * republished as current. A failure with nothing to fall back on reaches the + * caller and is never cached, so the next status check probes again. + */ +export const makeClaudeCapabilitiesCache = Effect.fn("makeClaudeCapabilitiesCache")(function* ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, +) { + // Keyed on binary + resolved HOME so account metadata never crosses instances. + const key = yield* makeClaudeCapabilitiesCacheKey(claudeSettings, cwd, environment); + // Emptied when it answers for a failed probe, so a second failure in a row surfaces. + const lastGood = yield* Ref.make(undefined); + const cache = yield* Cache.makeWith( + (_key: string) => + probeClaudeCapabilities(claudeSettings, environment, cwd).pipe( + Effect.tap((capabilities) => Ref.set(lastGood, capabilities)), + Effect.catch((error) => + Effect.flatMap(Ref.getAndSet(lastGood, undefined), (last) => { + if (!last) return Effect.fail(error); + const { usage: _staleUsage, ...capabilities } = last; + return Effect.succeed(capabilities); + }), + ), + ), + { + capacity: 1, + timeToLive: Exit.match({ + onSuccess: () => CAPABILITIES_PROBE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + return { + get: Cache.get(cache, key), + invalidate: Cache.invalidateAll(cache), + }; +}); + const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, args: ReadonlyArray, @@ -419,9 +472,9 @@ const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( claudeSettings: ClaudeSettings, - resolveCapabilities?: ( + resolveCapabilities: ( claudeSettings: ClaudeSettings, - ) => Effect.Effect, + ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, @@ -533,31 +586,35 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ); const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); - const capabilities = resolveCapabilities - ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) - : undefined; + const probeResult = yield* resolveCapabilities(claudeSettings).pipe(Effect.result); const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); - const slashCommands = [COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? [])]; - const dedupedSlashCommands = dedupeSlashCommands(slashCommands); - if (!capabilities) { + if (Result.isFailure(probeResult)) { return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, models, - slashCommands: dedupedSlashCommands, + slashCommands: [COMPACT_SLASH_COMMAND], skills, probe: { installed: true, version: parsedVersion, status: "warning", auth: { status: "unknown" }, - message: "Could not verify Claude authentication status from initialization result.", + // A failed probe says nothing about the login, so name what failed. + message: Cause.isTimeoutError(probeResult.failure) + ? `Timed out after ${CAPABILITIES_PROBE_TIMEOUT_MS / 1_000}s while checking Claude account status.` + : "Claude Agent CLI failed while checking account status.", }, }); } + const capabilities = probeResult.success; + const dedupedSlashCommands = dedupeSlashCommands([ + COMPACT_SLASH_COMMAND, + ...capabilities.slashCommands, + ]); const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9022890b2e3d..e6ad5c55e810 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -3,6 +3,7 @@ import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; import { ServerEnvironmentIdentity } from "../../environment/ServerEnvironment.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, it, assert } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -169,9 +170,6 @@ function claudeCapabilities(overrides: Partial = {}) { }); } -const noClaudeCapabilities = () => - Effect.sync(() => undefined as TestClaudeCapabilities | undefined); - function mockHandle(result: { stdout: string; stderr: string; code: number }) { return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), @@ -3052,30 +3050,35 @@ it.layer(Layer.mergeAll(TestNodeServices, ServerSettingsModule.layerTest(), Test ); }); - it.effect("returns warning when the Claude initialization result is unavailable", () => + it.effect("says why the capability probe failed instead of reporting an auth problem", () => Effect.gen(function* () { - const status = yield* checkClaudeProviderStatus( - defaultClaudeSettings, - noClaudeCapabilities, + const timedOut = yield* checkClaudeProviderStatus(defaultClaudeSettings, () => + Effect.fail(new Cause.TimeoutError()), ); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.installed, true); - assert.strictEqual(status.auth.status, "unknown"); + assert.strictEqual(timedOut.status, "warning"); + assert.strictEqual(timedOut.installed, true); + assert.strictEqual(timedOut.auth.status, "unknown"); assert.strictEqual( - status.message, - "Could not verify Claude authentication status from initialization result.", + timedOut.message, + "Timed out after 25s while checking Claude account status.", + ); + + const failed = yield* checkClaudeProviderStatus(defaultClaudeSettings, () => + Effect.fail( + new Cause.UnknownError(new Error("Claude Code process exited with code 1")), + ), + ); + assert.strictEqual(failed.status, "warning"); + assert.strictEqual(failed.auth.status, "unknown"); + assert.strictEqual( + failed.message, + "Claude Agent CLI failed while checking account status.", ); }).pipe( Effect.provide( mockSpawnerLayer((args) => { const joined = args.join(" "); if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; - if (joined === "auth status") - return { - stdout: '{"loggedIn":false}\n', - stderr: "", - code: 1, - }; throw new Error(`Unexpected args: ${joined}`); }), ),