diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index e38c0b040af0..66ee81306a5a 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -1,7 +1,7 @@ --- title: Effect Service Conventions -model: gpt-5-6-sol -effort: medium +model: gpt-6-sol +effort: max input: incremental tools: - browse_code diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index b90c81ab0a49..ab37120ff344 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -1,7 +1,7 @@ --- title: UI Consistency -model: gpt-5-6-sol -effort: medium +model: gpt-6-sol +effort: max input: incremental tools: - browse_code diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 811093a5a842..bb807ab95684 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -4,6 +4,7 @@ import { makeTraceSink, otlpSerializationLayer, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -719,7 +720,10 @@ const telemetryLayer = Layer.unwrap( Effect.forEach(resolved.warnings, (warning) => Effect.logWarning(warning)), ); - return otelWarningsLayer.pipe(Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer))); + return otelWarningsLayer.pipe( + Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer)), + Layer.provide(OtelEnvironment.layerResourceAttributes(resolved.resource.attributes)), + ); }), ); diff --git a/apps/desktop/src/app/DesktopOtlpExport.ts b/apps/desktop/src/app/DesktopOtlpExport.ts index b76d2f99c0f5..6dfb433f086c 100644 --- a/apps/desktop/src/app/DesktopOtlpExport.ts +++ b/apps/desktop/src/app/DesktopOtlpExport.ts @@ -113,8 +113,8 @@ export const resolveDesktopOtlpExport = (input: DesktopOtlpExportInput): Desktop const { otel } = input; const resource: DesktopOtlpResource = { serviceName: input.serviceName, - serviceVersion: otel.resource.serviceVersion, - attributes: { ...otel.resource.attributes, ...input.runtimeAttributes }, + serviceVersion: otel.serviceVersion, + attributes: { ...otel.resourceAttributes, ...input.runtimeAttributes }, }; if (otel.disabled) { diff --git a/apps/marketing/public/harnesses/antigravity.png b/apps/marketing/public/harnesses/antigravity.png deleted file mode 100644 index df1e22dbbd21..000000000000 Binary files a/apps/marketing/public/harnesses/antigravity.png and /dev/null differ diff --git a/apps/marketing/public/harnesses/antigravity.svg b/apps/marketing/public/harnesses/antigravity.svg new file mode 100644 index 000000000000..13e1ec9e9849 --- /dev/null +++ b/apps/marketing/public/harnesses/antigravity.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/apps/marketing/public/harnesses/opencode-dark.svg b/apps/marketing/public/harnesses/opencode-dark.svg index fc467bf84407..8c5e734ece6c 100644 --- a/apps/marketing/public/harnesses/opencode-dark.svg +++ b/apps/marketing/public/harnesses/opencode-dark.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 669bdce8a72d..15a175e0317e 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -37,7 +37,7 @@ const mobileEndorsementRows = [
-
+
Antigravity
Google sign-in
@@ -709,11 +709,6 @@ const mobileEndorsementRows = [ object-fit: contain; } - /* The Antigravity icon ships with its own rounded dark tile, so it fills the - card edge to edge instead of sitting inside it. */ - .hf-antigravity .hero-float-card { background: #0d0d10; border-color: rgba(255, 255, 255, 0.1); } - .hf-antigravity .hero-float-card img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; } - @keyframes mark-in { from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); } to { opacity: 1; transform: translate(0, 0) rotate(var(--rot)) scale(1); } @@ -829,7 +824,7 @@ const mobileEndorsementRows = [ flex-shrink: 0; width: 28px; height: 28px; display: grid; place-items: center; } - .harness-mark img { width: 22px; height: 22px; object-fit: contain; border-radius: 5px; } + .harness-mark img { width: 22px; height: 22px; object-fit: contain; } .harness-meta { flex: 1; min-width: 0; } .harness-name { diff --git a/apps/mobile/assets/antigravity.png b/apps/mobile/assets/antigravity.png index df1e22dbbd21..ecf863511c66 100644 Binary files a/apps/mobile/assets/antigravity.png and b/apps/mobile/assets/antigravity.png differ diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts index 3c81d8231216..f48ba92396e9 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.test.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskBranchLabel, resolveNewTaskLocalWorkspaceSelection, @@ -126,3 +127,26 @@ describe("resolveNewTaskBranchLabel", () => { ).toBe("Choose branch"); }); }); + +describe("filterNewTaskBranches", () => { + const branches = [ + { name: "main", isRemote: false }, + { name: "Feature/Login-Page", isRemote: false }, + { name: "origin/fix/remote-only", isRemote: true }, + ]; + const search = (query: string) => + filterNewTaskBranches(branches, query).map((branch) => branch.name); + + it("ignores case in both the query and the branch name", () => { + expect(search("feature/login")).toEqual(["Feature/Login-Page"]); + expect(search("MAIN")).toEqual(["main"]); + }); + + it("keeps remote-only branches searchable", () => { + expect(search("remote-only")).toEqual(["origin/fix/remote-only"]); + }); + + it("matches a typed space against the dash a branch name uses", () => { + expect(search(" login page ")).toEqual(["Feature/Login-Page"]); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-context-presentation.ts b/apps/mobile/src/features/threads/new-task-context-presentation.ts index 99eee3ea48ae..87c9b2595f4f 100644 --- a/apps/mobile/src/features/threads/new-task-context-presentation.ts +++ b/apps/mobile/src/features/threads/new-task-context-presentation.ts @@ -1,3 +1,5 @@ +import { sanitizeNewRefName } from "@t3tools/shared/git"; + type WorkspaceMode = "local" | "worktree"; export function resolveNewTaskWorkspaceLabel(input: { @@ -81,3 +83,13 @@ export function shouldCheckoutNewTaskBranch(input: { }): boolean { return input.workspaceMode === "local" && !input.branchIsCurrent && !input.branchWorktreePath; } + +export function filterNewTaskBranches( + branches: ReadonlyArray, + rawQuery: string, +): ReadonlyArray { + const query = sanitizeNewRefName(rawQuery).toLowerCase(); + return query.length === 0 + ? branches + : branches.filter((branch) => branch.name.toLowerCase().includes(query)); +} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 4d069bca9d05..61b77afffba1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -18,6 +18,7 @@ import { T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; +import { sanitizeNewRefName } from "@t3tools/shared/git"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import * as Arr from "effect/Array"; @@ -92,6 +93,7 @@ import { } from "../../state/legacy-plan-mode"; import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; import { + filterNewTaskBranches, resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; @@ -129,6 +131,9 @@ export function branchBadgeLabel(input: { if (input.branch.worktreePath && input.branch.worktreePath !== input.project?.workspaceRoot) { return "worktree"; } + if (input.branch.isRemote) { + return "remote"; + } if (input.branch.isDefault) { return "default"; } @@ -626,7 +631,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } replaceComposerDraftAttachments(selectedProjectDraftKey, []); }, [selectedProjectDraftKey]); - const debouncedBranchQuery = useDebouncedValue(branchQuery, BRANCH_SEARCH_DEBOUNCE_MS); + const branchSearchQuery = sanitizeNewRefName(branchQuery); + const debouncedBranchQuery = useDebouncedValue(branchSearchQuery, BRANCH_SEARCH_DEBOUNCE_MS); const branchTarget = useMemo( () => ({ environmentId: selectedProject?.environmentId ?? null, @@ -637,7 +643,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [debouncedBranchQuery, selectedProject?.environmentId, selectedProject?.workspaceRoot], ); const branchState = usePaginatedBranches(branchTarget); - const branchSearchIsDebouncing = branchQuery.trim() !== debouncedBranchQuery.trim(); + const branchSearchIsDebouncing = branchSearchQuery !== debouncedBranchQuery; const branchesLoading = branchSearchIsDebouncing || (branchState.isPending && branchState.data === null); const branchesFetchingNextPage = branchState.isFetchingNextPage; @@ -669,17 +675,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const currentCheckoutBranchName = projectGitStatus.data?.refName ?? null; - const filteredBranches = useMemo(() => { - const query = branchQuery.trim().toLowerCase(); - if (query.length === 0) { - return availableBranches; - } - - return pipe( - availableBranches, - Arr.filter((branch) => branch.name.toLowerCase().includes(query)), - ); - }, [availableBranches, branchQuery]); + const filteredBranches = useMemo( + () => filterNewTaskBranches(allBranchRefs, branchQuery), + [allBranchRefs, branchQuery], + ); // The composer's draft follows the project it will be sent to: switching // mid-compose keeps the same draft and moves it, so typed text follows the diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index cbec55e6aa80..bf4877cef692 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -129,11 +129,11 @@ export const layer = (config: ServerConfig["Service"]) => Layer.succeed(ServerCo */ export const otlpResource = (config: ServerConfig["Service"]) => ({ serviceName: config.otlpServiceName, - ...(config.otelEnvironment.resource.serviceVersion === undefined + ...(config.otelEnvironment.serviceVersion === undefined ? {} - : { serviceVersion: config.otelEnvironment.resource.serviceVersion }), + : { serviceVersion: config.otelEnvironment.serviceVersion }), attributes: { - ...config.otelEnvironment.resource.attributes, + ...config.otelEnvironment.resourceAttributes, "service.runtime": "t3-server", "service.mode": config.mode, }, diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 3627ceed46f2..b9dac2a1f644 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -116,6 +116,9 @@ export const ObservabilityLive = Layer.unwrap( Layer.provideMerge( Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer), ), + Layer.provide( + OtelEnvironment.layerResourceAttributes(config.otelEnvironment.resourceAttributes), + ), ); }), ); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ad2ed15342d7..7613b0ae678d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -31,6 +31,8 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; 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, @@ -60,7 +62,11 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey } from "./ClaudeHome.ts"; +import { + makeClaudeCapabilitiesCacheKey, + makeClaudeContinuationGroupKey, + resolveClaudeHomePath, +} from "./ClaudeHome.ts"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); @@ -88,6 +94,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ export type ClaudeDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner + | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -113,6 +120,7 @@ export const ClaudeDriver: ProviderDriver = { const path = yield* Path.Path; const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; const secretResolver = yield* ProviderSecretResolver; @@ -144,6 +152,12 @@ export const ClaudeDriver: ProviderDriver = { effectiveConfig, processEnv, ); + const configDir = yield* resolveClaudeHomePath(effectiveConfig, processEnv); + const accountConfigPath = yield* ClaudeResetCredits.claudeAccountConfigPath( + effectiveConfig.homePath.trim() || processEnv.CLAUDE_CONFIG_DIR?.trim() + ? configDir + : undefined, + ); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, @@ -198,6 +212,12 @@ export const ClaudeDriver: ProviderDriver = { cwd, resolveClaudeModelCatalog(manifest), scopedLimitNames, + (version) => + ClaudeResetCredits.readClaudeResetCredits(configDir, version).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), ), ), Effect.map(stampIdentity), @@ -256,6 +276,68 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(Path.Path, path), ); + // Same rules as Codex: serialised on the config directory that holds the + // login, one request id kept until Claude answers (a cooldown or rate + // limit is an answer), then a re-probe. + const consumeResetCredit: NonNullable = () => + Effect.gen(function* () { + const current = yield* snapshot.getSnapshot; + const grantId = current.usageLimits?.resetCredits?.nextCreditId; + if (!grantId || !current.version) return "noCredit" as const; + const version = current.version; + return yield* resetCreditCoordinator.redeem( + configDir, + (requestId) => + ClaudeResetCredits.consumeClaudeResetCredit({ + configDir, + accountConfigPath, + version, + grantId, + requestId, + }), + ClaudeResetCredits.isSettledClaudeResetCreditFailure, + ); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + cause._tag === "ClaudeResetCreditError" + ? cause.message + : "Claude could not redeem the reset.", + cause, + }), + ), + // Re-probe after any answer, but only a reset claims the limits + // changed, so only a reset reports an unconfirmed refresh. + Effect.tap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + yield* Cache.invalidateAll(capabilitiesProbeCache); + const refreshed = yield* snapshot.refresh; + const after = refreshed.usageLimits?.checkedAt; + if ( + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") + ) { + return yield* new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "The reset was applied, but Claude could not confirm the new limits. Refresh to check.", + }); + } + }), + ), + ); + return { instanceId, driverKind: DRIVER_KIND, @@ -271,6 +353,7 @@ export const ClaudeDriver: ProviderDriver = { snapshotForCwd, adapter, textGeneration, + consumeResetCredit, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index d6a0b5e0c29d..09c459f3cd3e 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -17,7 +17,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { @@ -34,7 +34,7 @@ const testLayer = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(codexResetCreditLayerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge( Layer.mock(BackgroundPolicy.BackgroundPolicy)({ shouldRunScopeWork: () => Effect.succeed(false), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 97a84931c9e8..7a092a10451d 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -37,10 +37,7 @@ import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { - CODEX_RESET_CREDIT_TIMEOUT, - CodexResetCreditCoordinator, -} from "../Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "../Layers/resetCreditCoordinator.ts"; import { checkCodexProviderStatus, makePendingCodexProvider, @@ -107,7 +104,7 @@ function makeCodexMaintenanceResolver(sharedHomePath: string) { export type CodexDriverEnv = | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner - | CodexResetCreditCoordinator + | ResetCreditCoordinator.ResetCreditCoordinator | Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient @@ -129,7 +126,7 @@ export const CodexDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const resetCreditCoordinator = yield* CodexResetCreditCoordinator; + const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator; const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; @@ -304,7 +301,7 @@ export const CodexDriver: ProviderDriver = { idempotencyKey, }); return response.outcome; - }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)), + }).pipe(Effect.scoped, Effect.timeout("20 seconds")), ) .pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -320,16 +317,19 @@ export const CodexDriver: ProviderDriver = { // The windows just changed; re-probe so the snapshot says so. A // failed probe republishes the pre-redemption limits rather than // marking them failed, so "confirmed" means `checkedAt` moved - // past what was published before the redemption started. - Effect.tap(() => + // past what was published before the redemption started. Only a + // reset claims the limits changed, so only a reset reports an + // unconfirmed refresh. + Effect.tap((outcome) => Effect.gen(function* () { const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; const refreshed = yield* snapshot.refresh; const after = refreshed.usageLimits?.checkedAt; if ( - after === undefined || - after === before || - refreshed.usageLimits?.unavailable?.reason === "probeFailed" + outcome === "reset" && + (after === undefined || + after === before || + refreshed.usageLimits?.unavailable?.reason === "probeFailed") ) { return yield* new ProviderDriverError({ driver: DRIVER_KIND, diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index 352a1374a0eb..5700986f3493 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -776,6 +776,44 @@ it.layer(layer)("AntigravityAdapter", (it) => { }), ); + it.effect("stops commands left running after a turn when the idle turn is stopped", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + yield* h.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + const sending = yield* h.adapter + .sendTurn({ threadId, input: "Start a watcher" }) + .pipe(Effect.forkChild); + const prompt = yield* h.nextPrompt; + yield* h.emitNative({ + _tag: "ToolCallUpdated", + toolCall: { + toolCallId: "watcher-1", + kind: "execute", + status: "inProgress", + command: "tail -f log", + data: {}, + }, + rawPayload: {}, + }); + yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" }); + yield* Fiber.join(sending); + const started = yield* h.waitForEvent((event) => event.type === "task.started"); + + // Monitoring's Stop reaches the adapter as a turn interrupt. With no + // prompt to cancel, it has to end the session to stop the command. + yield* h.adapter.interruptTurn(threadId); + const stopped = yield* h.waitForEvent((event) => event.type === "task.completed"); + expect(stopped.payload).toMatchObject({ taskId: started.payload.taskId, status: "stopped" }); + yield* h.waitForEvent((event) => event.type === "session.exited"); + expect(yield* h.adapter.hasSession(threadId)).toBe(false); + expect(h.controls.closed).toBe(1); + }), + ); + it.effect("keeps a launched batch active while child tools continue", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 61a9b3c3a645..0bab3a98a537 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1165,14 +1165,36 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const interruptTurn: Adapter["interruptTurn"] = (threadId) => Effect.gen(function* () { const context = yield* requireSession(threadId); + // A command that outlived its turn keeps running in the agent, and + // session/cancel only stops a prompt. The agent kills its background + // commands when its session closes, so Stop with nothing else running + // ends the session, as Claude's does. The next turn resumes it. + let idleWithCommands = false; yield* context.promptLock .withPermit( Effect.gen(function* () { + // Decided under the prompt lock so a turn cannot start in between. + if (!context.promptFiber && [...context.commands.values()].some((c) => c.promoted)) { + context.stopped = true; + idleWithCommands = true; + return; + } yield* cancelRequests(context); yield* context.runtime.cancel; }), ) - .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause))); + .pipe( + Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)), + // Once marked stopped the session must close, even if this call is + // interrupted, or it is left unreachable with its commands running. + Effect.ensuring( + Effect.suspend(() => + idleWithCommands + ? withThreadLock(threadId, stopContext(context)).pipe(Effect.ignore) + : Effect.void, + ), + ), + ); }); const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) => diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 37b2ad8004cf..9ab097c03ae3 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -2,6 +2,7 @@ import { type ClaudeSettings, type ModelCapabilities, type ServerProviderSlashCommand, + type ServerProviderResetCredits, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -492,6 +493,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, /** Shared with the adapter so turn events reuse the scoped-bucket names this probe saw. */ scopedLimitNames?: Ref.Ref, + /** Banked resets for a subscription login, given the CLI version for the user agent. */ + resolveResetCredits?: (version: string) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -673,6 +676,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, }) : claudeUsageResponseToLimits({ response: capabilities.usage, checkedAt }).limits; + const resetCredits = + resolveResetCredits && + capabilities.subscriptionType && + !usageLimits.unavailable && + parsedVersion + ? yield* resolveResetCredits(parsedVersion) + : undefined; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -690,7 +700,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(authMetadata ? authMetadata : {}), }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), - usageLimits, + usageLimits: resetCredits ? { ...usageLimits, resetCredits } : usageLimits, }, }); }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 3e54c4bcde2f..556297f64c40 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -35,7 +35,7 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; -import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -62,7 +62,7 @@ import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; -import * as CodexResetCredit from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { makeProviderInstanceRegistry } from "./ProviderInstanceRegistryLive.ts"; @@ -185,6 +185,7 @@ const makeTildeProviderFixtures = Effect.fn( claudePath, [ "#!/usr/bin/env node", + 'import { existsSync } from "node:fs";', 'import * as NodeReadline from "node:readline";', 'if (process.argv.includes("--version")) {', ' process.stdout.write("claude 2.1.219\\n");', @@ -193,7 +194,26 @@ const makeTildeProviderFixtures = Effect.fn( "const lines = NodeReadline.createInterface({ input: process.stdin });", 'lines.on("line", (line) => {', " const message = JSON.parse(line);", - ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + ' if (message.type !== "control_request") return;', + ' if (message.request?.subtype === "get_usage") {', + " const marker = process.env.T3_CLAUDE_RESET_MARKER;", + " if (process.env.T3_CLAUDE_USAGE_FAILS_AFTER_CLAIM && marker && existsSync(marker)) {", + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "error", request_id: message.request_id, error: "usage failed" },', + ' }) + "\\n");', + " return;", + " }", + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + ' response: { subtype: "success", request_id: message.request_id, response: {', + ' session: {}, subscription_type: "pro", rate_limits_available: true,', + " rate_limits: { five_hour: { utilization: marker && existsSync(marker) ? 0 : 100, resets_at: null } },", + " } },", + ' }) + "\\n");', + " return;", + " }", + ' if (message.request?.subtype !== "initialize") return;', " process.stdout.write(JSON.stringify({", ' type: "control_response",', " response: {", @@ -246,7 +266,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ProviderSecretResolverPassthroughLayer), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); it.live("boots two independent codex instances from a ProviderInstanceConfigMap", () => @@ -353,6 +373,44 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("reports Codex's answer when a redemption changed nothing", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + const fileSystem = yield* FileSystem.FileSystem; + const fixtures = yield* makeTildeProviderFixtures(); + yield* fileSystem.writeFileString( + fixtures.codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ + rootThreadId: "probe-thread", + notifications: [], + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + failRateLimitsRead: true, + resetCreditOutcome: "alreadyRedeemed", + }), + ); + const codexId = ProviderInstanceId.make("codex_reset"); + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver], + configMap: { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { name: "T3_CODEX_COLLAB_SCRIPT", value: fixtures.codexScriptPath, sensitive: false }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + }, + }); + const codex = yield* registry.getInstance(codexId); + expect(codex).toBeDefined(); + // The usage read fails, so the re-probe cannot confirm new limits. + yield* codex!.snapshot.refresh; + expect(yield* codex!.consumeResetCredit!()).toBe("alreadyRedeemed"); + }).pipe(Effect.provide(testLayer)), + ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => Effect.gen(function* () { if (yield* isHostWindows) return; @@ -407,6 +465,96 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + const redeemClaudeReset = (claim: { result: string; usageFailsAfterClaim: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixtures = yield* makeTildeProviderFixtures(); + const marker = path.join(fixtures.claudeHomePath, "redeemed"); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"fake-token"}}', + ); + yield* fs.writeFileString( + path.join(fixtures.claudeHomePath, ".claude.json"), + '{"oauthAccount":{"organizationUuid":"fake-org"}}', + ); + const client = HttpClient.make((request) => + Effect.gen(function* () { + if (request.url.endsWith("/api/oauth/usage")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { + eligible: true, + next_grant_id: "grant_a", + grants: [{ id: "grant_a", resets_left: 1, usable_now: true }], + }, + }), + ); + } + if (request.url.endsWith("/reset_rate_limits")) { + yield* fs.writeFileString(marker, "redeemed").pipe(Effect.orDie); + return HttpClientResponse.fromWeb(request, Response.json({ result: claim.result })); + } + return HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" })); + }), + ); + const instanceId = ProviderInstanceId.make("claude_reset"); + 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, + }), + }, + }, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + const instance = yield* registry.getInstance(instanceId); + expect(instance).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 }; + }).pipe( + // macOS logins live in the Keychain, where resets are never read. + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(testLayer), + ); + + it.live("refreshes Claude usage after redeeming a reset", () => + Effect.gen(function* () { + const { outcome, after } = yield* redeemClaudeReset({ + result: "reset", + usageFailsAfterClaim: false, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "reset" }); + expect(after.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({ + result: "already_used", + usageFailsAfterClaim: true, + }); + expect(outcome).toMatchObject({ _tag: "Success", success: "alreadyRedeemed" }); + }), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => @@ -475,7 +623,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ProviderSecretResolverPassthroughLayer), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); it.live("boots one instance of every shipped driver from a single config map", () => @@ -651,7 +799,7 @@ describe("ProviderInstanceRegistryLive: rebuildInstanceWhen", () => { Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ProviderSecretResolverPassthroughLayer), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), ); const codexDriverKind = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ecf2a109e01b..7db157b584f4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -39,7 +39,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { applyProviderCompatibility } from "../providerCompatibility.ts"; -import * as CodexResetCredit from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -2636,7 +2636,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -2757,7 +2757,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -2828,7 +2828,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -2945,7 +2945,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -3008,8 +3008,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), - Layer.provideMerge(CodexResetCredit.layerTest), + Layer.provideMerge(ResetCreditCoordinator.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( @@ -3581,6 +3580,43 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("reads banked resets only for subscription logins", () => + Effect.gen(function* () { + const check = (overrides: Partial) => + checkClaudeProviderStatus( + defaultClaudeSettings, + () => + Effect.succeed({ + email: undefined, + subscriptionType: undefined, + tokenSource: undefined, + apiKeySource: undefined, + apiProvider: undefined, + slashCommands: [], + usage: { rate_limits_available: true, rate_limits: {} }, + ...overrides, + }), + undefined, + undefined, + undefined, + undefined, + () => Effect.succeed({ availableCount: 2 }), + ); + const subscription = yield* check({ subscriptionType: "max" }); + const bedrock = yield* check({ apiProvider: "bedrock" }); + assert.deepStrictEqual(subscription.usageLimits?.resetCredits, { availableCount: 2 }); + assert.strictEqual(bedrock.usageLimits?.resetCredits, undefined); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("does not duplicate Claude in full subscription labels", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/claudeResetCredits.test.ts b/apps/server/src/provider/Layers/claudeResetCredits.test.ts new file mode 100644 index 000000000000..17f9a03c48bd --- /dev/null +++ b/apps/server/src/provider/Layers/claudeResetCredits.test.ts @@ -0,0 +1,259 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it as effectIt } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse, UrlParams } from "effect/unstable/http"; +import { describe, expect, it } from "vite-plus/test"; + +import * as ClaudeResetCredits from "./claudeResetCredits.ts"; + +const NOW = Date.parse("2026-09-22T12:00:00.000Z"); +const grant = (overrides: Record) => ({ + id: "grant_a", + resets_left: 1, + usable_now: true, + ...overrides, +}); + +const writeLogin = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + path.join(directory, ".credentials.json"), + '{"claudeAiOauth":{"accessToken":"oauth-token"}}', + ); + const accountConfigPath = path.join(directory, ".claude.json"); + yield* fs.writeFileString(accountConfigPath, '{"oauthAccount":{"organizationUuid":"org-1"}}'); + return { configDir: directory, accountConfigPath }; +}); + +const respond = (status: number, body: unknown) => + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body, { status }))), + ); +const refuseRequests = HttpClient.make(() => Effect.die("must not send a request")); + +describe("claudeResetCreditsToContract", () => { + it("counts live grants and pins the next usable one", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { + eligible: true, + next_grant_id: "grant_a", + grants: [ + grant({ resets_left: 2, ends_at: "2026-10-01T00:00:00Z" }), + grant({ id: "paused", paused: true }), + grant({ id: "expired", ends_at: "2026-09-01T00:00:00Z" }), + grant({ id: "garbled", ends_at: "not a date" }), + grant({ id: "date_only", ends_at: "2026-10-01" }), + grant({ id: "impossible", ends_at: "2027-02-30T00:00:00Z" }), + grant({ id: "empty", ends_at: "" }), + grant({ id: "Not Valid" }), + grant({ id: "grant_b", resets_left: 3, usable_now: false }), + ], + }, + NOW, + ), + ).toEqual({ + availableCount: 2, + nextCreditId: "grant_a", + nextExpiresAt: "2026-10-01T00:00:00.000Z", + }); + }); + + it("offers nothing to redeem without a usable next grant or an eligible account", () => { + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: true, next_grant_id: "grant_a", grants: [grant({ usable_now: false })] }, + NOW, + ), + ).toEqual({ availableCount: 0 }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract({ eligible: true, grants: [grant({})] }, NOW), + ).toEqual({ + availableCount: 0, + }); + expect( + ClaudeResetCredits.claudeResetCreditsToContract( + { eligible: false, grants: [grant({})] }, + NOW, + ), + ).toBeUndefined(); + expect(ClaudeResetCredits.claudeResetCreditsToContract(undefined, NOW)).toBeUndefined(); + }); +}); + +effectIt.layer(NodeServices.layer)("readClaudeResetCredits", (it) => { + it.effect("reads the grants with the CLI's request", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const client = HttpClient.make((request) => { + expect(request.method).toBe("GET"); + expect(request.url).toBe("https://api.anthropic.com/api/oauth/usage"); + expect(UrlParams.toString(request.urlParams)).toBe("cedar_ember=1&skip_spend=1"); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + expect(request.headers["anthropic-beta"]).toBe("oauth-2025-04-20"); + expect(request.headers["user-agent"]).toBe("claude-cli/2.1.0 (external, cli)"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + cedar_ember: { eligible: true, next_grant_id: "grant_a", grants: [grant({})] }, + }), + ), + ); + }); + const credits = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(credits).toEqual({ availableCount: 1, nextCreditId: "grant_a" }); + }), + ); + + it.effect("reads nothing from keychain logins or failed requests", () => + Effect.gen(function* () { + const { configDir } = yield* writeLogin; + const darwin = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HttpClient.HttpClient, refuseRequests), + ); + const limited = yield* ClaudeResetCredits.readClaudeResetCredits(configDir, "2.1.0").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, respond(429, {})), + ); + expect([darwin, limited]).toEqual([undefined, undefined]); + }), + ); +}); + +const ClaimBody = Schema.fromJsonString( + Schema.Struct({ program: Schema.String, grant_id: Schema.String, request_id: Schema.String }), +); +const decodeClaimBody = Schema.decodeEffect(ClaimBody); + +const consume = (client: HttpClient.HttpClient, ids = { grantId: "grant_a", requestId: "r-1" }) => + Effect.gen(function* () { + const login = yield* writeLogin; + return yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + ...ids, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + ); + }); + +effectIt.layer(NodeServices.layer)("consumeClaudeResetCredit", (it) => { + it.effect("claims the grant for the organization", () => + Effect.gen(function* () { + const client = HttpClient.make((request) => + Effect.gen(function* () { + expect(request.method).toBe("POST"); + expect(request.url).toBe( + "https://api.anthropic.com/api/organizations/org-1/reset_rate_limits", + ); + expect(request.headers.authorization).toBe("Bearer oauth-token"); + const body = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + expect(yield* decodeClaimBody(body)).toEqual({ + program: "cedar_ember", + grant_id: "grant_a", + request_id: "r-1", + }); + return HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + }).pipe(Effect.orDie), + ); + expect(yield* consume(client)).toMatchObject({ _tag: "Success", success: "reset" }); + }), + ); + + it.effect("maps each answer to an outcome or a failure", () => + Effect.gen(function* () { + for (const [result, outcome] of [ + ["not_limited", "nothingToReset"], + ["already_used", "alreadyRedeemed"], + ["ineligible", "noCredit"], + ] as const) { + expect(yield* consume(respond(200, { result }))).toMatchObject({ success: outcome }); + } + for (const client of [ + respond(200, { result: "cooldown" }), + respond(429, {}), + respond(401, {}), + ]) { + const result = yield* consume(client); + expect(result).toMatchObject({ _tag: "Failure" }); + // Claude answered, so a retry must be a new claim. + if (result._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(result.failure)).toBe(true); + } + } + // No answer, or Claude could not confirm the claim: a retry is the same claim. + for (const client of [respond(500, {}), respond(200, { result: "unavailable" })]) { + const unanswered = yield* consume(client); + expect(unanswered).toMatchObject({ _tag: "Failure" }); + if (unanswered._tag === "Failure") { + expect(ClaudeResetCredits.isSettledClaudeResetCreditFailure(unanswered.failure)).toBe( + false, + ); + } + } + }), + ); + + it.effect("times out a stalled claim body", () => + Effect.gen(function* () { + const login = yield* writeLogin; + const readingBody = yield* Deferred.make(); + const client = HttpClient.make((request) => { + const response = HttpClientResponse.fromWeb(request, Response.json({ result: "reset" })); + Object.defineProperty(response, "json", { + value: Deferred.succeed(readingBody, undefined).pipe(Effect.andThen(Effect.never)), + }); + return Effect.succeed(response); + }); + const claim = yield* ClaudeResetCredits.consumeClaudeResetCredit({ + ...login, + version: "2.1.0", + grantId: "grant_a", + requestId: "r-1", + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HttpClient.HttpClient, client), + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(readingBody); + yield* TestClock.adjust("26 seconds"); + expect(yield* Fiber.join(claim)).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "ClaudeResetCreditError", + reason: "requestFailed", + cause: { _tag: "TimeoutError" }, + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("refuses malformed ids without sending anything", () => + Effect.gen(function* () { + for (const ids of [ + { grantId: "Bad Grant", requestId: "r-1" }, + { grantId: "grant_a", requestId: "has space" }, + ]) { + expect(yield* consume(refuseRequests, ids)).toMatchObject({ _tag: "Failure" }); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.ts b/apps/server/src/provider/Layers/claudeResetCredits.ts new file mode 100644 index 000000000000..925a25585491 --- /dev/null +++ b/apps/server/src/provider/Layers/claudeResetCredits.ts @@ -0,0 +1,270 @@ +/** + * Claude banked resets (the CLI's `cedar_ember` program). The CLI reads the + * grants from the OAuth usage endpoint and claims one against the + * organization; this module does the same with the credentials the CLI keeps + * in its config directory. macOS keeps them in the keychain, so there the + * feature is not offered. + * + * @module provider/Layers/claudeResetCredits + */ +import * as NodeOS from "node:os"; +import type { + ProviderConsumeResetCreditOutcome, + ServerProviderResetCredits, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +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, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +const API_BASE = "https://api.anthropic.com"; +const PROGRAM = "cedar_ember"; +const GRANT_ID = /^[a-z0-9_-]{1,40}$/; +const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/; +const COMPLETE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +const Credentials = Schema.Struct({ + claudeAiOauth: Schema.optional(Schema.Struct({ accessToken: Schema.optional(Schema.String) })), +}); +const Config = Schema.Struct({ + oauthAccount: Schema.optional( + Schema.Struct({ organizationUuid: Schema.optional(Schema.String) }), + ), +}); +const Grant = Schema.Struct({ + id: Schema.String.check(Schema.isPattern(GRANT_ID)), + resets_left: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + ends_at: Schema.optional(Schema.NullOr(Schema.String)), + paused: Schema.optional(Schema.Boolean), + usable_now: Schema.optional(Schema.Boolean), +}); +const decodeGrant = Schema.decodeUnknownOption(Grant); +const CedarEmber = Schema.Struct({ + eligible: Schema.Boolean, + grants: Schema.optional(Schema.Array(Schema.Unknown)), + next_grant_id: Schema.optional(Schema.NullOr(Schema.String)), +}); +const UsageResponse = Schema.Struct({ + cedar_ember: Schema.optional(Schema.NullOr(Schema.Unknown)), +}); +const decodeCedarEmber = Schema.decodeUnknownOption(CedarEmber); +const ClaimResponse = Schema.Struct({ + result: Schema.Literals([ + "reset", + "already_used", + "not_limited", + "cooldown", + "ineligible", + "unavailable", + ]), +}); + +const RESET_CREDIT_FAILURES = { + malformedCredit: "Claude returned a malformed reset credit.", + loginUnreadable: "Claude could not read its login.", + accountUnreadable: "Claude could not read its account.", + signedOut: "Sign in to Claude again to redeem resets.", + rateLimited: "Claude is rate limiting resets. Try again soon.", + coolingDown: "Claude resets are cooling down. Try again later.", + unconfirmed: + "Claude could not confirm the reset. If you are still limited in a moment, try again.", + requestFailed: "Claude could not redeem the reset.", +} as const; + +class ClaudeResetCreditError extends Schema.TaggedError()( + "ClaudeResetCreditError", + { + reason: Schema.Literals( + Object.keys(RESET_CREDIT_FAILURES) as Array, + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return RESET_CREDIT_FAILURES[this.reason]; + } +} + +const isClaudeResetCreditError = Schema.is(ClaudeResetCreditError); + +/** + * Every reset failure except `requestFailed` and `unconfirmed` is final: + * Claude answered, or nothing was sent. An unanswered or unconfirmed claim + * retries with the same request id. + */ +export const isSettledClaudeResetCreditFailure = (error: unknown) => + isClaudeResetCreditError(error) && + error.reason !== "requestFailed" && + error.reason !== "unconfirmed"; + +/** Rejects unparseable and calendar-invalid timestamps such as February 30. */ +const isFutureTimestamp = (value: string, nowMs: number) => { + if (!COMPLETE_TIMESTAMP.test(value)) return false; + const [year, month, day] = value.slice(0, 10).split("-").map(Number); + return ( + Date.parse(value) > nowMs && Date.UTC(year!, month! - 1, day!) <= Date.UTC(year!, month!, 0) + ); +}; + +/** Grants that are paused or past `ends_at` cannot be claimed and do not count. */ +export function claudeResetCreditsToContract( + block: unknown, + nowMs: number, +): ServerProviderResetCredits | undefined { + const parsed = decodeCedarEmber(block); + if (Option.isNone(parsed) || !parsed.value.eligible) return undefined; + const live = (parsed.value.grants ?? []) + .flatMap((raw) => Option.toArray(decodeGrant(raw))) + .filter( + (grant) => + !grant.paused && + grant.usable_now && + (grant.ends_at == null || isFutureTimestamp(grant.ends_at, nowMs)), + ); + const next = live.find((grant) => grant.id === parsed.value.next_grant_id); + const nextExpiresAt = next?.ends_at ? DateTime.make(next.ends_at) : Option.none(); + return { + availableCount: next ? live.reduce((sum, grant) => sum + grant.resets_left, 0) : 0, + ...(Option.isSome(nextExpiresAt) + ? { nextExpiresAt: DateTime.formatIso(nextExpiresAt.value) } + : {}), + ...(next ? { nextCreditId: next.id } : {}), + }; +} + +const readJson = (schema: S, file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(schema))), + ); + }); + +const readAccessToken = (configDir: string) => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "darwin") return undefined; + const path = yield* Path.Path; + const credentials = yield* readJson(Credentials, path.join(configDir, ".credentials.json")); + return credentials.claudeAiOauth?.accessToken?.trim() || undefined; + }); + +const withClaudeHeaders = (token: string, version: string) => + HttpClientRequest.setHeaders({ + authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + "user-agent": `claude-cli/${version} (external, cli)`, + }); + +/** + * Reads the banked resets for the login in `configDir`. Any failure reads as + * "no resets" so the usage bars never break on this optional extra. + */ +export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( + function* (configDir: string, version: string) { + const token = yield* readAccessToken(configDir); + if (!token) return undefined; + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get(`${API_BASE}/api/oauth/usage`, { + urlParams: { cedar_ember: "1", skip_spend: "1" }, + }).pipe(withClaudeHeaders(token, version)), + ); + const body = yield* HttpClientResponse.schemaBodyJson(UsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return claudeResetCreditsToContract( + body.cedar_ember, + DateTime.toEpochMillis(yield* DateTime.now), + ); + }, + Effect.timeout("10 seconds"), + Effect.catch(() => Effect.succeed(undefined)), +); + +/** The CLI keeps the account record beside its settings, or in the home directory by default. */ +export const claudeAccountConfigPath = (configDir: string | undefined) => + Effect.map(Path.Path, (path) => + configDir ? path.join(configDir, ".claude.json") : path.join(NodeOS.homedir(), ".claude.json"), + ); + +const CLAIM_OUTCOMES = { + reset: "reset", + not_limited: "nothingToReset", + already_used: "alreadyRedeemed", + ineligible: "noCredit", +} as const satisfies Record; + +/** + * Claims `grantId`. `requestId` is the idempotency key: a retry with the same + * id is the same claim. Ids are checked before anything is sent. + */ +export const consumeClaudeResetCredit = Effect.fn("consumeClaudeResetCredit")(function* (input: { + readonly configDir: string; + readonly accountConfigPath: string; + readonly version: string; + readonly grantId: string; + readonly requestId: string; +}) { + if (!GRANT_ID.test(input.grantId) || !REQUEST_ID.test(input.requestId)) { + return yield* new ClaudeResetCreditError({ reason: "malformedCredit" }); + } + const token = yield* readAccessToken(input.configDir).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "loginUnreadable", cause })), + ); + const config = yield* readJson(Config, input.accountConfigPath).pipe( + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "accountUnreadable", cause })), + ); + const organization = config.oauthAccount?.organizationUuid?.trim(); + if (!token || !organization) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute( + HttpClientRequest.post( + new URL( + `/api/organizations/${encodeURIComponent(organization)}/reset_rate_limits`, + API_BASE, + ), + ).pipe( + withClaudeHeaders(token, input.version), + HttpClientRequest.bodyJsonUnsafe({ + program: PROGRAM, + grant_id: input.grantId, + request_id: input.requestId, + }), + ), + ) + .pipe( + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (response.status === 429) { + return yield* new ClaudeResetCreditError({ reason: "rateLimited" }); + } + if (response.status === 401 || response.status === 403) { + return yield* new ClaudeResetCreditError({ reason: "signedOut" }); + } + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ClaimResponse)), + Effect.timeout("25 seconds"), + Effect.mapError((cause) => new ClaudeResetCreditError({ reason: "requestFailed", cause })), + ); + if (body.result === "cooldown") { + return yield* new ClaudeResetCreditError({ reason: "coolingDown" }); + } + // Claude could not say whether the claim landed, so, like the CLI, keep the + // request id and let the retry ask about the same claim. + if (body.result === "unavailable") { + return yield* new ClaudeResetCreditError({ reason: "unconfirmed" }); + } + return CLAIM_OUTCOMES[body.result]; +}); diff --git a/apps/server/src/provider/Layers/codexResetCredit.test.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts similarity index 69% rename from apps/server/src/provider/Layers/codexResetCredit.test.ts rename to apps/server/src/provider/Layers/resetCreditCoordinator.test.ts index f03b29737209..88de498d43dc 100644 --- a/apps/server/src/provider/Layers/codexResetCredit.test.ts +++ b/apps/server/src/provider/Layers/resetCreditCoordinator.test.ts @@ -4,12 +4,12 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; -import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; -describe("CodexResetCreditCoordinator", () => { +describe("ResetCreditCoordinator", () => { it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const keys = yield* Ref.make>([]); const attempts = yield* Ref.make(0); const consume = (key: string) => @@ -31,12 +31,31 @@ describe("CodexResetCreditCoordinator", () => { assert.strictEqual(seen.length, 3); assert.strictEqual(seen[0], seen[1]); assert.notStrictEqual(seen[1], seen[2]); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), + ); + + it.effect("starts a fresh attempt after a settled failure", () => + Effect.gen(function* () { + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const consume = (key: string) => + Ref.update(keys, (seen) => [...seen, key]).pipe( + Effect.andThen(Effect.fail("cooldown" as const)), + ); + const isSettled = (error: "cooldown") => error === "cooldown"; + + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + yield* redeem("acct", consume, isSettled).pipe(Effect.result); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 2); + assert.notStrictEqual(seen[0], seen[1]); + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); it.effect("serialises concurrent redemptions on the same account, not per caller", () => Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const release = yield* Deferred.make(); const inFlight = yield* Ref.make(0); const peak = yield* Ref.make(0); @@ -58,12 +77,12 @@ describe("CodexResetCreditCoordinator", () => { yield* Fiber.join(b); assert.strictEqual(yield* Ref.get(peak), 1); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); it.effect("keeps different accounts independent", () => Effect.gen(function* () { - const { redeem } = yield* CodexResetCreditCoordinator; + const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; const release = yield* Deferred.make(); const peak = yield* Ref.make(0); const inFlight = yield* Ref.make(0); @@ -81,6 +100,6 @@ describe("CodexResetCreditCoordinator", () => { yield* Fiber.join(a); yield* Fiber.join(b); assert.strictEqual(yield* Ref.get(peak), 2); - }).pipe(Effect.provide(layerTest)), + }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), ); }); diff --git a/apps/server/src/provider/Layers/codexResetCredit.ts b/apps/server/src/provider/Layers/resetCreditCoordinator.ts similarity index 70% rename from apps/server/src/provider/Layers/codexResetCredit.ts rename to apps/server/src/provider/Layers/resetCreditCoordinator.ts index 34bd0a77fe27..c481624e7f40 100644 --- a/apps/server/src/provider/Layers/codexResetCredit.ts +++ b/apps/server/src/provider/Layers/resetCreditCoordinator.ts @@ -1,49 +1,44 @@ /** - * Redeeming a Codex reset credit is an account-level action: instances that - * share the directory holding `auth.json` share the credit, so their + * Redeeming a reset credit is an account-level action: instances that share + * the directory holding a provider's login share the credit, so their * redemptions must serialise on that directory, not the instance. This * service keeps one lock and one pending idempotency key per account key so * overlapping confirmations from any instance queue rather than spending two * credits, and a retry after a timeout re-sends the same attempt. * - * @module provider/Layers/codexResetCredit + * @module provider/Layers/resetCreditCoordinator */ import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import type * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; -/** - * Bounded so a hung app-server cannot hold the account lock forever; the - * timeout interrupts the scoped request, which kills the process, and the - * kept idempotency key makes the user's retry safe. - */ -export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20); - interface AccountRedemptionState { readonly lock: Semaphore.Semaphore; readonly pendingKey: Ref.Ref; } -export class CodexResetCreditCoordinator extends Context.Service< - CodexResetCreditCoordinator, +export class ResetCreditCoordinator extends Context.Service< + ResetCreditCoordinator, { /** * Run `consume` under the account's lock with a stable idempotency key. - * The key is cleared only when Codex reports an outcome; a failure - * (timeout included) keeps it so the next attempt is the same attempt. + * The key is cleared when the provider reports an outcome, or when + * `isSettled` says a failure was a final answer (such as a cooldown). + * Any other failure (timeout included) keeps it so the next attempt is + * the same attempt. */ readonly redeem: ( accountKey: string, consume: (idempotencyKey: string) => Effect.Effect, + isSettled?: (error: E) => boolean, ) => Effect.Effect; } ->()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {} +>()("t3/provider/Layers/resetCreditCoordinator") {} /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { @@ -52,9 +47,7 @@ export const make = Effect.gen(function* () { // Get-or-create through one Ref.modify so two first redemptions for the // same account cannot each install their own lock. - const stateFor = Effect.fn("CodexResetCreditCoordinator.stateFor")(function* ( - accountKey: string, - ) { + const stateFor = Effect.fn("ResetCreditCoordinator.stateFor")(function* (accountKey: string) { const existing = (yield* Ref.get(statesRef)).get(accountKey); if (existing) return existing; const candidate = { @@ -70,7 +63,7 @@ export const make = Effect.gen(function* () { }); }); - const redeem: CodexResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume) => + const redeem: ResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume, isSettled) => Effect.gen(function* () { const state = yield* stateFor(accountKey); return yield* state.lock.withPermits(1)( @@ -78,24 +71,28 @@ export const make = Effect.gen(function* () { const existing = yield* Ref.get(state.pendingKey); const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); yield* Ref.set(state.pendingKey, idempotencyKey); - const outcome = yield* consume(idempotencyKey); + const outcome = yield* consume(idempotencyKey).pipe( + Effect.tapError((error) => + isSettled?.(error) ? Ref.set(state.pendingKey, null) : Effect.void, + ), + ); yield* Ref.set(state.pendingKey, null); return outcome; }), ); }); - return { redeem } satisfies CodexResetCreditCoordinator["Service"]; + return { redeem } satisfies ResetCreditCoordinator["Service"]; }); -export const layer = Layer.effect(CodexResetCreditCoordinator, make); +export const layer = Layer.effect(ResetCreditCoordinator, make); /** * Self-contained for tests: a counter-backed Crypto so keys are deterministic * and distinct without the platform layer. */ export const layerTest = Layer.effect( - CodexResetCreditCoordinator, + ResetCreditCoordinator, Effect.gen(function* () { let counter = 0; return yield* make.pipe( diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 440f1558a684..59ebc18c5fcf 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -84,7 +84,18 @@ rl.on("line", (line) => { return; } if (method === "account/read") { - write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + write({ + id, + result: { account: script.account ?? { type: "apiKey" }, requiresOpenaiAuth: false }, + }); + return; + } + if (method === "account/rateLimits/read" && script.failRateLimitsRead) { + write({ id, error: { code: -32000, message: "usage unavailable" } }); + return; + } + if (method === "account/rateLimitResetCredit/consume" && script.resetCreditOutcome) { + write({ id, result: { outcome: script.resetCreditOutcome } }); return; } if (method === "skills/list" || method === "model/list") { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b1c7e720bedf..d0661e14e33d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -46,7 +46,7 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import * as ModelManifest from "./provider/ModelManifest.ts"; -import * as CodexResetCredit from "./provider/Layers/codexResetCredit.ts"; +import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; @@ -537,7 +537,7 @@ const RuntimeCoreDependenciesWithoutThreadBootstrapLive = 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, CodexResetCredit.layer), + Layer.mergeAll(ProviderEventLoggers.layer, ModelManifest.layer, ResetCreditCoordinator.layer), ), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 98dec86522de..4a5dff870fd2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1449,6 +1449,51 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + for (const [timestamp, splitIndex] of [ + [1_700_000_000, false], + [1_700_000_000.9999, false], + [1_700_000_000, true], + [1_700_000_000.9999, true], + ] as const) { + it.effect( + `preserves same-size edits with a racy review index (${timestamp}, split: ${splitIndex})`, + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(cwd, "tracked.txt"); + const indexPath = path.join(cwd, ".git", "index"); + // Reproduce a same-timestamp edit without relying on filesystem clock resolution. + yield* git(cwd, ["config", "core.trustctime", "false"]); + yield* writeTextFile(cwd, "tracked.txt", "before\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* git(cwd, ["add", "tracked.txt"]); + yield* git(cwd, ["commit", "-m", "record racy file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + yield* fileSystem.utimes(indexPath, timestamp, timestamp); + const originalIndex = yield* fileSystem.readFile(indexPath); + const originalIndexMtime = (yield* fileSystem.stat(indexPath)).mtime; + yield* writeTextFile(cwd, "tracked.txt", "after!\n"); + yield* fileSystem.utimes(filePath, timestamp, timestamp); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + + const preview = yield* driver.getReviewDiffPreview({ cwd }); + const dirty = preview.sources.find((source) => source.kind === "working-tree")!; + assert.deepStrictEqual(dirty.files, [ + { path: "tracked.txt", previousPath: null, additions: 1, deletions: 1 }, + { path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }, + ]); + assert.include(dirty.diff, "-before"); + assert.include(dirty.diff, "+after!"); + assert.deepStrictEqual(yield* fileSystem.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fileSystem.stat(indexPath)).mtime, originalIndexMtime); + }), + ); + } + it.effect("keeps complete stats for files beyond the combined patch limit", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5fbae919c258..47fd3bd20725 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2360,7 +2360,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* prefix: `t3code-review-index-${process.pid}-`, }); const indexExists = yield* fileSystem.exists(indexPath); - if (indexExists) yield* fileSystem.copyFile(indexPath, tempIndexPath); + if (indexExists) { + const { mtime } = yield* fileSystem.stat(indexPath); + yield* fileSystem.copyFile(indexPath, tempIndexPath); + // A newer copy timestamp hides racily clean edits. Round down before Git reads or rewrites it. + const indexTime = Option.isSome(mtime) + ? Math.max(0, Math.floor((mtime.value.getTime() - 1) / 1000)) + : 0; + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + } const env = { GIT_INDEX_FILE: tempIndexPath } satisfies NodeJS.ProcessEnv; const tempIndexConfig = [ "-c", diff --git a/apps/web/src/browser/BrowserDeviceToolbar.tsx b/apps/web/src/browser/BrowserDeviceToolbar.tsx index a8c9027f8a16..390c77de4424 100644 --- a/apps/web/src/browser/BrowserDeviceToolbar.tsx +++ b/apps/web/src/browser/BrowserDeviceToolbar.tsx @@ -175,7 +175,7 @@ export function BrowserDeviceToolbar({ }} > {width >= 560 ? ( - + Dimensions ) : null} diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 6c303afb27e9..35044cd9c169 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -352,7 +352,7 @@ export function HostedBrowserWebview(props: { /> {activeDrag ? (
{agent.title} {role ? ( - + {role} ) : null} - + {agent.status === "completed" ? ( @@ -183,7 +183,7 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { > {activity ?? statusLabel} - + {metadata.join(" · ")} {statusLabel} @@ -234,7 +234,7 @@ function PhaseRail({ group }: { group: AgentPanelWorkflowGroup }) { > {phase.members.length === 0 ? ( - – + – ) : ( phase.members.map((member) => ) )} @@ -281,7 +281,7 @@ function WorkflowScriptView({
- + {scriptPath.split("/").at(-1)}
- +
); }); @@ -2421,7 +2430,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {project.displayName}
{project.groupedProjectCount > 1 ? ( - + {project.groupedProjectCount} projects ) : null} diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index cf88d4965f09..482de3bb9f36 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -19,7 +19,7 @@ const DynamicIcon = lazy(() => ); function DynamicProjectIconFallback() { - return ; + return ; } // The slice of a project that decides its icon. Every surface must pass the @@ -176,7 +176,7 @@ function ProjectFaviconImage({ handleLoadError(displayedSrc)} /> ) : null} diff --git a/apps/web/src/components/PullRequestContextDetails.tsx b/apps/web/src/components/PullRequestContextDetails.tsx index cb2aae9ac52d..6462299c4093 100644 --- a/apps/web/src/components/PullRequestContextDetails.tsx +++ b/apps/web/src/components/PullRequestContextDetails.tsx @@ -15,7 +15,7 @@ export function PullRequestContextDetails({ metadata }: { metadata: PullRequestC {state.label}
{metadata.title}
-
+
{metadata.headBranch}