From 8e57177debc2c2fe8ba362e3247030e60c290c75 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:20:36 +0200 Subject: [PATCH] fix(server): one failed Claude probe no longer sticks as an auth warning One stalled capability probe replaced a ready Claude snapshot with "Could not verify Claude authentication status", cached that result for five minutes, and dropped the cause. The probe now fails with its real error and logs the tag. When a probe fails, the last good probe answers once more, so one stall keeps Claude ready with the same account and slash commands. A second failure in a row, or a failure before any good probe, shows a warning that says whether the probe timed out or the CLI failed. The cache never keeps that failure. Fixes #13635 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/provider/Drivers/ClaudeDriver.ts | 33 ++----- .../Layers/ClaudeCapabilitiesProbe.test.ts | 63 ++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 97 +++++++++++++++---- .../provider/Layers/ProviderRegistry.test.ts | 39 ++++---- 4 files changed, 169 insertions(+), 63 deletions(-) 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}`); }), ),