diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index a7ff41fc89ea..33b11237547e 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; import { + claudeSignedOutMessage, makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, makeClaudeEnvironment, @@ -39,6 +40,17 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { }), ); + it("points the signed-out hint at the configured Claude home", () => { + expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain( + "run `claude auth login`", + ); + const configDir = "/synthetic/Claude work's $literal"; + const message = claudeSignedOutMessage({ configDir, cwd: "/synthetic/project" }); + expect(message).toContain(`CLAUDE_CONFIG_DIR set to "${configDir}"`); + 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: "" }; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index b3ef22b640cf..bbd005a1e00b 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -3,9 +3,12 @@ import * as NodeOS from "node:os"; import type { ClaudeSettings } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { expandHomePath } from "../../pathExpansion.ts"; +const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( config: Pick, ): Effect.fn.Return { @@ -50,3 +53,18 @@ export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesC 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. + */ +export const claudeSignedOutMessage = (input: { + readonly configDir: string | undefined; + readonly cwd: string; +}): string => { + const configuration = + input.configDir !== undefined + ? ` from ${quotePath(input.cwd)}, with CLAUDE_CONFIG_DIR set to ${quotePath(input.configDir)}` + : ""; + return `Claude could not authenticate. For subscription login, run \`claude auth login\` on this environment's machine${configuration}, then start a new thread. For API-key authentication, check this instance's configured credentials.`; +}; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 13b44c1669fa..3b3f427d0cbb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -168,6 +168,7 @@ function makeHarness(config?: { readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; readonly scopedLimitNames?: ClaudeAdapterLiveOptions["scopedLimitNames"]; + readonly environment?: ClaudeAdapterLiveOptions["environment"]; }) { const query = new FakeClaudeQuery(); let createInput: @@ -178,6 +179,7 @@ function makeHarness(config?: { | undefined; const adapterOptions: ClaudeAdapterLiveOptions = { + ...(config?.environment ? { environment: config.environment } : {}), ...(config?.instanceId ? { instanceId: config.instanceId } : {}), ...(config?.scopedLimitNames ? { scopedLimitNames: config.scopedLimitNames } : {}), modelCatalog: Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), @@ -2199,6 +2201,411 @@ describe("ClaudeAdapterLive", () => { ); }); + const AUTH_FAILURE_ASSISTANT = { + type: "assistant", + session_id: "sdk-session-auth", + uuid: "assistant-auth", + parent_tool_use_id: null, + error: "authentication_failed", + is_api_error_message: true, + message: { + id: "assistant-message-auth", + model: "", + content: [{ type: "text", text: "Not logged in \u00b7 Please run /login" }], + }, + } as unknown as SDKMessage; + + const completedTurn = (runtimeEvents: ReadonlyArray) => { + const event = runtimeEvents[runtimeEvents.length - 1]; + assert.equal(event?.type, "turn.completed"); + assert(event?.type === "turn.completed"); + return event.payload; + }; + + it.effect.each([ + { + name: "an api_error terminal reason", + result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] }, + state: "failed", + errorMessage: /claude auth login/, + }, + { + name: "an is_error success with no terminal reason", + result: { subtype: "success", is_error: true, errors: [] }, + state: "failed", + errorMessage: /claude auth login/, + }, + // Every other outcome names its own cause, and the latch must not speak over it. + { + name: "a terminal reason of its own", + result: { + subtype: "success", + is_error: false, + terminal_reason: "prompt_too_long", + errors: [], + }, + state: "failed", + errorMessage: /prompt exceeds the model's context window/, + }, + { + name: "a listed tool failure", + result: { + subtype: "error_during_execution", + is_error: true, + errors: ["Tool execution failed: EACCES"], + }, + state: "failed", + errorMessage: /EACCES/, + }, + { + name: "a user interrupt", + result: { + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_tools", + errors: [], + }, + state: "interrupted", + errorMessage: undefined, + }, + { + name: "a cancellation", + result: { subtype: "error_during_execution", is_error: true, errors: ["cancelled"] }, + state: "cancelled", + errorMessage: /cancelled/, + }, + ])( + "reports the real cause when an expired login is followed by $name", + ({ result, state, errorMessage }) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + + harness.query.emit(AUTH_FAILURE_ASSISTANT); + harness.query.emit({ + type: "result", + ...result, + session_id: "sdk-session-auth", + uuid: "result-auth", + } as unknown as SDKMessage); + + const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber))); + assert.equal(payload.state, state); + if (errorMessage === undefined) { + assert.equal(payload.errorMessage, undefined); + } else { + assert.match(payload.errorMessage ?? "", errorMessage); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("fails a usage-limited turn with the limit it parked on", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + + const nowMs = yield* Clock.currentTimeMillis; + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: "rejected", + rateLimitType: "five_hour", + resetsAt: Math.floor(nowMs / 1000) + 2 * 60 * 60, + }, + session_id: "sdk-session-limit", + uuid: "rate-limit-rejected", + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + terminal_reason: "api_error", + errors: [], + session_id: "sdk-session-limit", + uuid: "result-limit", + } as unknown as SDKMessage); + + const payload = completedTurn(Array.from(yield* Fiber.join(runtimeEventsFiber))); + assert.equal(payload.state, "failed"); + assert.equal( + payload.errorMessage, + "Claude usage limit reached. Send the message again once the limit resets.", + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect.each([ + { + name: "listed error with api_error", + evidence: "auth", + result: { + subtype: "error_during_execution", + is_error: true, + terminal_reason: "api_error", + errors: ["Tool execution failed: EACCES"], + }, + expected: /EACCES/, + expectedState: "failed", + }, + { + name: "overload with api_error", + evidence: "auth", + result: { + subtype: "success", + is_error: true, + terminal_reason: "api_error", + api_error_status: 529, + errors: [], + }, + expected: /overloaded \(529\)/, + expectedState: "failed", + }, + { + name: "listed error on an is_error success", + evidence: "auth", + result: { + subtype: "success", + is_error: true, + errors: ["Tool execution failed: EACCES"], + }, + expected: /EACCES/, + expectedState: "failed", + }, + { + name: "recovered same window", + evidence: "recovered", + result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] }, + expected: /repeated API errors/, + expectedState: "failed", + }, + { + name: "nested assistant does not poison parent", + evidence: "nested-auth", + result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] }, + expected: /repeated API errors/, + expectedState: "failed", + }, + ...[ + "recovered-missing-reset", + "recovered-next-reset", + "recovered-warning", + "two-windows-recovered", + ].map((evidence) => ({ + name: evidence, + evidence, + result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] }, + expected: /repeated API errors/, + expectedState: "failed", + })), + ...["two-windows-one-recovered", "rejected-again"].map((evidence) => ({ + name: evidence, + evidence, + result: { subtype: "success", is_error: false, terminal_reason: "api_error", errors: [] }, + expected: /usage limit reached/, + expectedState: "failed", + })), + { + name: "successful turn stays successful", + evidence: "recovered", + result: { subtype: "success", is_error: false, errors: [] }, + expected: undefined, + expectedState: "completed", + }, + ])( + "preserves terminal failure evidence after $name", + ({ evidence, result, expected, expectedState }) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "synthetic hello", + attachments: [], + }); + if (evidence === "auth" || evidence === "nested-auth") { + harness.query.emit({ + type: "assistant", + session_id: "sdk-audit", + uuid: "audit-auth", + parent_tool_use_id: evidence === "nested-auth" ? "synthetic-parent-tool" : null, + error: "authentication_failed", + is_api_error_message: true, + message: { + id: "audit-message", + model: "synthetic-audit-model", + content: [{ type: "text", text: "Not logged in. Please run /login" }], + }, + } as unknown as SDKMessage); + } else { + const nowMs = yield* Clock.currentTimeMillis; + const resetsAt = Math.floor(nowMs / 1000) + 7200; + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour", resetsAt }, + session_id: "sdk-audit", + uuid: "audit-limit-rejected", + } as unknown as SDKMessage); + if (evidence.startsWith("two-windows")) { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "seven_day", resetsAt }, + session_id: "sdk-audit", + uuid: "audit-weekly-rejected", + } as unknown as SDKMessage); + } + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: evidence === "recovered-warning" ? "allowed_warning" : "allowed", + rateLimitType: "five_hour", + ...(evidence === "recovered-missing-reset" + ? {} + : { + resetsAt: evidence === "recovered-next-reset" ? resetsAt + 18000 : resetsAt, + }), + }, + session_id: "sdk-audit", + uuid: "audit-limit-allowed", + } as unknown as SDKMessage); + if (evidence === "two-windows-recovered" || evidence === "rejected-again") { + harness.query.emit({ + type: "rate_limit_event", + rate_limit_info: { + status: evidence === "rejected-again" ? "rejected" : "allowed", + rateLimitType: evidence === "rejected-again" ? "five_hour" : "seven_day", + resetsAt, + }, + session_id: "sdk-audit", + uuid: "audit-final-quota-update", + } as unknown as SDKMessage); + } + } + harness.query.emit({ + type: "result", + ...result, + session_id: "sdk-audit", + uuid: "audit-result", + } as unknown as SDKMessage); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const complete = events.at(-1); + assert(complete?.type === "turn.completed"); + assert.equal(complete.payload.state, expectedState); + if (expected) assert.match(complete.payload.errorMessage ?? "", expected); + else assert.equal(complete.payload.errorMessage, undefined); + if (evidence === "rejected-again") + assert.equal(events.filter((event) => event.type === "runtime.warning").length, 1); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect.each([ + { homePath: "./synthetic config's $literal", inherited: undefined }, + { homePath: "", inherited: ".synthetic config's $literal" }, + { homePath: "", inherited: " /synthetic/path with edge spaces " }, + ])( + "reports the same Claude config and cwd used by the spawned query ($homePath, $inherited)", + ({ homePath, inherited }) => { + const harness = makeHarness({ + claudeConfig: { homePath }, + environment: { ...process.env, CLAUDE_CONFIG_DIR: inherited }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + const cwd = NodePath.resolve("/tmp/synthetic-audit-project"); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + cwd, + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "synthetic", + attachments: [], + }); + harness.query.emit(AUTH_FAILURE_ASSISTANT); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + terminal_reason: "api_error", + errors: [], + session_id: "sdk-session-auth", + uuid: "result-auth", + } as unknown as SDKMessage); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const completed = events.at(-1); + assert(completed?.type === "turn.completed"); + const actualQuery = harness.getLastCreateQueryInput(); + assert(actualQuery !== undefined); + const expectedConfigDir = homePath ? NodePath.resolve(homePath) : inherited; + assert.equal(actualQuery.options.env?.CLAUDE_CONFIG_DIR, expectedConfigDir); + assert.equal(actualQuery.options.cwd, cwd); + assert( + completed.payload.errorMessage?.includes( + `CLAUDE_CONFIG_DIR set to ${encodeUnknownJsonString(expectedConfigDir)}`, + ), + ); + assert(completed.payload.errorMessage?.includes(`from ${encodeUnknownJsonString(cwd)}`)); + assert(!completed.payload.errorMessage?.includes("CLAUDE_CONFIG_DIR=")); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("fails a turn for every dead-turn terminal_reason", () => { const reasons = [ "blocking_limit", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 1ecde618e191..e46024fa4078 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -83,7 +83,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { claudeSignedOutMessage, makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { planClaudeSkillDispatch } from "../Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; @@ -159,6 +159,8 @@ interface ClaudeTurnState { compactedSinceLatestAssistantUsage: boolean; hasSubagents: boolean; nextSyntheticAssistantBlockIndex: number; + authenticationFailureMessage: string | undefined; + rejectedRateLimitTypes: Set; } interface AssistantTextBlockState { @@ -435,10 +437,13 @@ function resultErrorsText(result: SDKResultMessage): string { } /** Failure text for structured terminal reasons, including success-tagged failures. */ -function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined { +function terminalResultError( + reason: SDKResultMessage["terminal_reason"], + failureHint?: string, +): string | undefined { switch (reason) { case "api_error": - return "Claude gave up after repeated API errors."; + return failureHint ?? "Claude gave up after repeated API errors."; case "malformed_tool_use_exhausted": return "Claude gave up after repeated malformed tool calls."; case "budget_exhausted": @@ -1552,18 +1557,31 @@ function isOverloadedResult(result: SDKResultMessage): boolean { } /** Derives turn status and its error from the same provider result. */ -function resultOutcome(result: SDKResultMessage): { +function resultOutcome( + result: SDKResultMessage, + failureHint?: string, +): { status: ProviderRuntimeTurnStatus; errorMessage: string | undefined; } { + // A success result flagged is_error only fails when the turn already + // reported its cause (expired login, rejected usage window). + const successTaggedFailure = result.subtype === "success" && result.is_error === true; const structuredError = isOverloadedResult(result) ? "Claude API is overloaded (529). Try again shortly." - : terminalResultError(result.terminal_reason); - // CLI diagnostic entries must not become the error banner. + : (terminalResultError(result.terminal_reason, failureHint) ?? + (successTaggedFailure ? failureHint : undefined)); + // CLI diagnostic entries must not become the error banner. Success results + // carry no typed error list, but a success-tagged failure may still list one. + const listedErrors: ReadonlyArray = + "errors" in result && Array.isArray(result.errors) ? result.errors : []; const listedError = - result.subtype === "success" || !Array.isArray(result.errors) + result.subtype === "success" && !successTaggedFailure ? undefined - : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); + : listedErrors.find( + (error): error is string => + typeof error === "string" && !error.startsWith("[ede_diagnostic]"), + ); const errorMessage = listedError || structuredError; if (structuredError !== undefined) return { status: "failed", errorMessage }; if (result.subtype === "success") return { status: "completed", errorMessage }; @@ -3148,6 +3166,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( compactedSinceLatestAssistantUsage: false, hasSubagents: false, nextSyntheticAssistantBlockIndex: -1, + authenticationFailureMessage: undefined, + rejectedRateLimitTypes: new Set(), }; context.session = { ...context.session, @@ -3206,6 +3226,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } if (context.turnState) { + // The CLI can report authentication failure before ending the turn as a + // generic API error, so retain that evidence for the result fallback. + if (message.error === "authentication_failed") { + context.turnState.authenticationFailureMessage = claudeSignedOutMessage({ + configDir: claudeEnvironment.CLAUDE_CONFIG_DIR, + cwd: path.resolve(context.session.cwd ?? "."), + }); + } context.turnState.items.push(message.message); if ( normalizeClaudeActiveTokenUsage( @@ -3232,7 +3260,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - const { status, errorMessage } = resultOutcome(message); + const turn = context.turnState; + const failureHint = + turn?.authenticationFailureMessage ?? + (turn && turn.rejectedRateLimitTypes.size > 0 + ? "Claude usage limit reached. Send the message again once the limit resets." + : undefined); + const { status, errorMessage } = resultOutcome(message, failureHint); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); @@ -3835,14 +3869,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Warnings (allowed_warning) still have headroom and stay quiet, an // account spending provisioned overage keeps running despite the reject, // and between turns there is no turn to report as paused. - if ( - rateLimitInfo.status === "rejected" && - rateLimitInfo.overageStatus !== "allowed" && - rateLimitInfo.overageStatus !== "allowed_warning" && - rateLimitInfo.isUsingOverage !== true && - rateLimitInfo.overageInUse !== true && - context.turnState !== undefined - ) { + const overageAllowed = + rateLimitInfo.overageStatus === "allowed" || + rateLimitInfo.overageStatus === "allowed_warning" || + rateLimitInfo.isUsingOverage === true || + rateLimitInfo.overageInUse === true; + const blocked = rateLimitInfo.status === "rejected" && !overageAllowed; + const limitType = rateLimitInfo.rateLimitType ?? "unknown"; + const limitKey = `${limitType}:${rateLimitInfo.resetsAt ?? "unknown"}`; + if (context.turnState) { + // Current blocking evidence is independent of whether its warning has + // already been shown. A recovery can omit or advance the reset time; + // its window type remains stable without clearing another window. + if (blocked) context.turnState.rejectedRateLimitTypes.add(limitType); + else if ( + rateLimitInfo.status === "allowed" || + rateLimitInfo.status === "allowed_warning" || + overageAllowed + ) { + context.turnState.rejectedRateLimitTypes.delete(limitType); + } + } + if (blocked && context.turnState !== undefined) { // Tracked per turn as a set of limit identities, not as the rendered // row: a parked window re-fires while the remaining wait shrinks, and a // turn can park on more than one window, so a single slot would let an @@ -3852,7 +3900,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( if (context.announcedUsageLimits?.turnId !== turnId) { context.announcedUsageLimits = { turnId, keys: new Set() }; } - const limitKey = `${rateLimitInfo.rateLimitType ?? "unknown"}:${rateLimitInfo.resetsAt ?? "unknown"}`; if (!context.announcedUsageLimits.keys.has(limitKey)) { context.announcedUsageLimits.keys.add(limitKey); const notice = describeClaudeUsageLimit( @@ -4900,6 +4947,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( compactedSinceLatestAssistantUsage: false, hasSubagents: false, nextSyntheticAssistantBlockIndex: -1, + authenticationFailureMessage: undefined, + rejectedRateLimitTypes: new Set(), }; const updatedAt = yield* nowIso;