diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 4bd38d7cfeb3b..f63ca4446776f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1015,6 +1015,7 @@ describe("CodexAdapterV2 session initialize", () => { runtimePolicy: CODEX_TEST_RUNTIME_POLICY, }), initializeRequests: () => initializeRequests, + events: runtime.events, }; }); @@ -1162,6 +1163,92 @@ describe("CodexAdapterV2 session initialize", () => { ), ); + it.effect("fails the open when initialize never answers during open", () => + Effect.gen(function* () { + const initializeAwaitingResponse = yield* Deferred.make(); + const opened = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "initialize-hung-open", + entries: replayPreamble("initialize-hung-open").slice(0, 2), + }), + (entry) => + entry.label === "initialize" + ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( + Effect.andThen(Effect.never), + ) + : Effect.void, + () => Effect.die("initialize never answers"), + ).pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initializeAwaitingResponse); + yield* TestClock.adjust("30 seconds"); + + assert.isDefined(opened.pollUnsafe(), "initialize must fail within its deadline"); + // The session manager discards a session whose open fails. + assert.instanceOf(yield* Fiber.join(opened), ProviderAdapter.ProviderAdapterOpenSessionError); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("retires the shared session when initialize never answers", () => + Effect.gen(function* () { + const initializeAwaitingResponse = yield* Deferred.make(); + const session = yield* openReplaySession( + makeCodexReplayTranscript({ + scenario: "initialize-hung", + entries: replayPreamble("initialize-hung").slice(0, 2), + }), + (entry) => + entry.label === "initialize" + ? Deferred.succeed(initializeAwaitingResponse, undefined).pipe( + Effect.andThen(Effect.never), + ) + : Effect.void, + ); + const eventsExit = yield* Stream.runDrain(session.events).pipe( + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + + const ensured = yield* session + .ensureThread("thread-initialize-hung") + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(initializeAwaitingResponse); + const waiting = yield* session + .ensureThread("thread-initialize-hung-waiting") + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("30 seconds"); + + assert.isDefined(ensured.pollUnsafe(), "the hung initializer must fail"); + assert.isDefined(waiting.pollUnsafe(), "waiting threads must fail with the shared session"); + assert.equal((yield* Fiber.join(ensured))._tag, "ProviderAdapterEnsureThreadError"); + // A waiter fails at once instead of re-sending initialize to the hung app-server. + assert.equal((yield* Fiber.join(waiting))._tag, "ProviderAdapterEnsureThreadError"); + assert.equal(session.initializeRequests(), 1); + // The session manager retires a session whose event stream fails. + const exit = yield* Fiber.join(eventsExit); + assert.isTrue(Exit.isFailure(exit)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("completes the handshake after a caller is interrupted mid-initialize", () => Effect.gen(function* () { const initializeAwaitingResponse = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 9bc651821ed37..d906cb2cfdcf1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -232,6 +232,8 @@ const decodeCodexBackgroundTerminalTerminateResponse = Schema.decodeUnknownEffec const decodeCodexBackgroundTerminalsListResponse = Schema.decodeUnknownEffect( CodexBackgroundTerminalsListResponse, ); +// Initialize normally answers in under a second; leave room for a slow first launch. +const CODEX_INITIALIZE_TIMEOUT = "30 seconds"; const CodexThreadSampleResponse = Schema.Struct({ text: Schema.NullOr(Schema.String) }); const decodeCodexThreadSampleResponse = Schema.decodeUnknownEffect(CodexThreadSampleResponse); export const CODEX_PROMPT_SUGGESTION_PROMPT = @@ -1803,7 +1805,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( }), ), ); + const events = yield* Queue.unbounded< + ProviderAdapter.ProviderAdapterV2Event, + ProviderAdapter.ProviderAdapterEventStreamError + >(); const initialized = yield* Ref.make(false); + // After a timeout the session is retiring; waiters must not re-send to the hung app-server. + const initializeTimeout = yield* Ref.make< + ProviderAdapter.ProviderAdapterEventStreamError | undefined + >(undefined); // Codex rejects a second initialize on the shared app-server. const initializePermit = yield* Semaphore.make(1); const ensureInitialized = initializePermit.withPermit( @@ -1812,6 +1822,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( if (alreadyInitialized) { return; } + const timedOut = yield* Ref.get(initializeTimeout); + if (timedOut !== undefined) return yield* timedOut; const userAgent = yield* client .request("initialize", { @@ -1827,6 +1839,23 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ? Effect.succeed(null) : Effect.fail(error), }), + // A hung app-server never answers, and every thread on this shared + // session waits behind the permit. Failing the event stream retires + // the session, so the next turn spawns a fresh app-server. + Effect.timeoutOrElse({ + duration: CODEX_INITIALIZE_TIMEOUT, + orElse: () => { + const error = new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: CODEX_PROVIDER, + providerSessionId: input.providerSessionId, + cause: `Codex app-server did not answer initialize within ${CODEX_INITIALIZE_TIMEOUT}.`, + }); + return Ref.set(initializeTimeout, error).pipe( + Effect.andThen(Queue.fail(events, error)), + Effect.andThen(Effect.fail(error)), + ); + }, + }), ); const version = userAgent && /\/(\S+)/u.exec(userAgent)?.[1]; const advisory = @@ -1856,10 +1885,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( model: input.modelSelection.model, now, }); - const events = yield* Queue.unbounded< - ProviderAdapter.ProviderAdapterV2Event, - ProviderAdapter.ProviderAdapterEventStreamError - >(); // Drain final notifications before the session manager retires the dead process. // Queue.fail lets readers take items already buffered before they see the failure. yield* client.awaitTermination.pipe( diff --git a/apps/server/src/project/ProjectService.deletion.test.ts b/apps/server/src/project/ProjectService.deletion.test.ts index 28b1c40541ddf..25e5ee54a2737 100644 --- a/apps/server/src/project/ProjectService.deletion.test.ts +++ b/apps/server/src/project/ProjectService.deletion.test.ts @@ -28,6 +28,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ProjectSettingsOverrideSweep from "../provider/ProjectSettingsOverrideSweep.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; @@ -501,9 +502,13 @@ it.effect("deletes a project without force once its imported threads were delete }).pipe(Effect.provide(layerDatabase)), ); -it.effect( - "clears a deleted project's settings overrides so a machine-disabled instance cannot stay alive", - () => +it.effect.each([ + { policy: "no", worktreeCleanup: undefined }, + { policy: "its off", worktreeCleanup: { mode: "off" } as const }, + { policy: "its legacy", worktreeCleanup: null }, +])( + "clears a deleted project's settings overrides, keeping $policy worktree cleanup policy, so a machine-disabled instance cannot stay alive", + ({ worktreeCleanup }) => Effect.gen(function* () { const projectId = ProjectId.make("project:overrides-cleanup"); const instanceId = ProviderInstanceId.make("codex_work"); @@ -514,7 +519,11 @@ it.effect( const serverSettings = yield* ServerSettings.ServerSettingsService; yield* serverSettings.updateSettings({ projectSettingsOverrides: { - [projectId]: { providerInstances: { [instanceId]: { enabled: true } } }, + [projectId]: { + providerInstances: { [instanceId]: { enabled: true } }, + defaultAutoPull: true, + ...(worktreeCleanup === undefined ? {} : { worktreeCleanup }), + }, }, }); @@ -523,8 +532,35 @@ it.effect( projectId, }); - const settingsAfterDelete = yield* serverSettings.getSettings; - assert.isUndefined(settingsAfterDelete.projectSettingsOverrides[projectId]); + const entry = (yield* serverSettings.getSettings).projectSettingsOverrides[projectId]; + if (worktreeCleanup === undefined) { + assert.isUndefined(entry); + } else { + assert.deepStrictEqual(entry, { worktreeCleanup, providerInstances: {} }); + } + + const liveProjectId = ProjectId.make("project:still-live"); + const missingProjectId = ProjectId.make("project:missing"); + yield* seedProject(liveProjectId); + // Older deletions or interrupted best-effort cleanup can leave the original overrides on restart. + yield* serverSettings.updateSettings({ + projectSettingsOverrides: { + [projectId]: { + providerInstances: { [instanceId]: { enabled: true } }, + defaultAutoPull: true, + ...(worktreeCleanup === undefined ? {} : { worktreeCleanup }), + }, + [liveProjectId]: { defaultAutoPull: true }, + [missingProjectId]: { worktreeCleanup: { mode: "off" } }, + }, + }); + yield* ProjectSettingsOverrideSweep.sweep; + assert.deepStrictEqual((yield* serverSettings.getSettings).projectSettingsOverrides, { + [liveProjectId]: { defaultAutoPull: true }, + ...(worktreeCleanup === undefined + ? {} + : { [projectId]: { worktreeCleanup, providerInstances: {} } }), + }); }).pipe(Effect.provide(layerServices)); }).pipe(Effect.provide(layerDatabase)), ); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index dd919251b7a37..0dfc09a0604c0 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -34,6 +34,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { deletedProjectOverridePatch } from "../provider/ProjectSettingsOverrideSweep.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -535,7 +536,14 @@ export const make = Effect.gen(function* () { // Best-effort cleanup releases disabled instances kept alive by overrides; crashes have no replay to revisit deletion. const clearProjectSettingsOverrides = (projectId: ProjectId) => - serverSettings.updateSettings({ projectSettingsOverrides: { [projectId]: null } }).pipe( + serverSettings.getSettings.pipe( + Effect.flatMap(({ projectSettingsOverrides }) => + serverSettings.updateSettings({ + projectSettingsOverrides: { + [projectId]: deletedProjectOverridePatch(projectSettingsOverrides[projectId]), + }, + }), + ), Effect.asVoid, Effect.catchCause((cause) => Effect.logWarning( diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 99e8516a70347..886d78bd68343 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -56,7 +56,6 @@ import { import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); -const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; type CodexRateLimitsProbe = | { @@ -589,7 +588,6 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun : withCodexDiscoveryDeadline( client.request("account/rateLimits/read", null), deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, ).pipe( Effect.map((response): CodexRateLimitsProbe => response diff --git a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts index aa7d205271494..06f61f9ad8bfe 100644 --- a/apps/server/src/provider/ProjectSettingsOverrideSweep.ts +++ b/apps/server/src/provider/ProjectSettingsOverrideSweep.ts @@ -1,32 +1,41 @@ -import { type ProjectId, type ServerSettings } from "@t3tools/contracts"; +import * as NodeUtil from "node:util"; + +import { type ProjectSettingsOverrides } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import * as Settings from "../serverSettings.ts"; -const findOrphanedProjectIds = ( - settings: Pick, - liveProjectIds: ReadonlySet, -): ReadonlyArray => - Object.keys(settings.projectSettingsOverrides) - .map((projectId) => projectId as ProjectId) - .filter((projectId) => !liveProjectIds.has(projectId)); +export function deletedProjectOverridePatch(entry: ProjectSettingsOverrides | undefined) { + // Keep cleanup policy for deleted threads; omitting providerInstances would preserve obsolete references in the merge. + return entry?.worktreeCleanup !== undefined + ? { worktreeCleanup: entry.worktreeCleanup, providerInstances: {} } + : null; +} export const sweep = Effect.gen(function* () { const serverSettings = yield* Settings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; const settings = yield* serverSettings.getSettings; - const liveShells = yield* projectStore.listShells(); - const liveProjectIds = new Set(liveShells.map((shell) => shell.id)); + const projects = yield* projectStore.list({ includeDeleted: true }); + const projectsById = new Map( + projects.map((project) => [project.projectId, project]), + ); - const orphanedProjectIds = findOrphanedProjectIds(settings, liveProjectIds); - if (orphanedProjectIds.length === 0) return; + const overrides = Object.entries(settings.projectSettingsOverrides).flatMap( + ([projectId, entry]) => { + const project = projectsById.get(projectId); + if (project?.deletedAt === null) return []; + const patch = project ? deletedProjectOverridePatch(entry) : null; + if (NodeUtil.isDeepStrictEqual(entry, patch)) return []; + return [[projectId, patch] as const]; + }, + ); + if (overrides.length === 0) return; yield* serverSettings.updateSettings({ - projectSettingsOverrides: Object.fromEntries( - orphanedProjectIds.map((projectId) => [projectId, null]), - ), + projectSettingsOverrides: Object.fromEntries(overrides), }); }).pipe( Effect.asVoid, diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 8234e8a68106c..e023523df827f 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -591,6 +592,86 @@ it.layer( }), ); + it.effect("shows Codex usage that answers after three seconds inside the probe budget", () => + Effect.gen(function* () { + const usageRequested = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const output = yield* Queue.unbounded(); + const send = (message: unknown) => + Queue.offer(output, encoder.encode(`${JSON.stringify(message)}\n`)); + const results: Record = { + initialize: { + userAgent: "codex_cli_rs/0.167.0", + codexHome: "/nonexistent-codex-home", + platformFamily: "unix", + platformOs: "linux", + }, + "account/read": { + account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + "config/read": { config: {}, origins: {} }, + "skills/list": { data: [] }, + "model/list": { data: [] }, + }; + const answer = (line: string) => { + const { id, method } = JSON.parse(line) as { id?: number; method?: string }; + if (id === undefined || method === undefined) return Effect.void; + if (method !== "account/rateLimits/read") { + return send({ id, result: results[method] ?? {} }); + } + return Deferred.succeed(usageRequested, undefined).pipe( + Effect.andThen(Effect.sleep("4 seconds")), + Effect.andThen( + send({ + id, + result: { + rateLimits: { + limitId: "codex", + planType: "pro", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 2e9 }, + }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + accountId: null, + }, + }), + ), + Effect.forkIn(scope), + ); + }; + const decoder = new TextDecoder(); + let buffered = ""; + return ChildProcessSpawner.makeHandle({ + ...mockHandle({ stdout: "", stderr: "", code: 0 }), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin: Sink.forEach((chunk: Uint8Array) => { + buffered += decoder.decode(chunk, { stream: true }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + return Effect.forEach(lines.filter(Boolean), answer, { discard: true }); + }), + stdout: Stream.fromQueue(output), + }); + }), + ); + const fiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(usageRequested); + yield* TestClock.adjust("25 seconds"); + const status = yield* Fiber.join(fiber); + + assert.strictEqual(status.auth.status, "authenticated"); + assert.isUndefined(status.usageLimits?.unavailable); + assert.strictEqual(status.usageLimits?.windows[0]?.usedPercent, 42); + }), + ); + it.effect("does not run --version when the Codex executable cannot start", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 3306e420b35c5..c63047c85ca1e 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -449,14 +449,17 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects binary files without leaking their contents into the error", () => + it.effect.each([ + { name: "a NUL byte", bytes: [0x61, 0, 0x62] }, + { name: "invalid UTF-8", bytes: [0x63, 0x61, 0x66, 0xe9] }, + ])("rejects files with $name without leaking their contents", ({ bytes }) => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const cwd = yield* makeTempDir; const absolutePath = path.join(cwd, "asset.bin"); - yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62])); + yield* fileSystem.writeFile(absolutePath, Uint8Array.from(bytes)); const error = yield* workspaceFileSystem .readFile({ cwd, relativePath: "asset.bin" }) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 489fedc2dce09..293ed205ab30f 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -423,22 +423,22 @@ export const make = Effect.gen(function* () { offset += bytesRead; } const fileBytes = buffer.subarray(0, offset); - if (fileBytes.includes(0)) { - return yield* new WorkspaceBinaryFileError({ + const truncated = stat.size > PROJECT_READ_FILE_MAX_BYTES; + const binaryFileError = () => + new WorkspaceBinaryFileError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, }); - } - - const truncated = stat.size > PROJECT_READ_FILE_MAX_BYTES; - return { - relativePath: target.relativePath, - // Streaming decode drops a character the byte limit split, instead of ending in U+FFFD. - contents: new TextDecoder("utf-8").decode(fileBytes, { stream: truncated }), - byteLength: stat.size, - truncated, - }; + if (fileBytes.includes(0)) return yield* binaryFileError(); + // Fatal decoding rejects invalid UTF-8, since U+FFFD contents would corrupt the file on save. + // Streaming decode drops a character the byte limit split, instead of rejecting it. + const contents = yield* Effect.try({ + try: () => + new TextDecoder("utf-8", { fatal: true }).decode(fileBytes, { stream: truncated }), + catch: binaryFileError, + }); + return { relativePath: target.relativePath, contents, byteLength: stat.size, truncated }; }), (handle) => handle === null diff --git a/docs/user/usage.md b/docs/user/usage.md index ab2386b74e4e2..a9e970648eaf0 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -161,6 +161,9 @@ the bar show each account's quota, countdown, and credits. Tap a row to open its In pooled summaries, the same account signed in on more than one environment, or reported by a hub as well, counts once. Filter with the environment dropdown to see what a single machine has. +For supported subscription tiers, pooled percentages and reset estimates account for plan capacity. +Claude uses this weighting for session allowances. A window with an unknown plan uses equal account weights. + When a Claude or Codex account has no overall weekly quota left, its pooled session contribution shows 0% left. The next refill depends on both reset times: an exhausted session must also reset, and a partly spent session may only regain its unused portion when the week resets. Missing or diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 2b3c5ca731c8b..6c1786e9fe7f2 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -933,6 +933,81 @@ describe("pools", () => { ).toEqual(["weekly", "weekly:secondary"]); }); + it("weights pooled Codex quota and resets by each account's plan capacity", () => { + const [pool] = collectLimitPools( + collectLimitAccounts( + new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + ["pro", "ChatGPT Pro 200 Subscription", 100], + ["plus", "ChatGPT Plus Subscription", 0], + ].map(([id, plan, usedPercent]) => ({ + id: String(id), + email: `${id}@example.com`, + driver: ProviderDriverKind.make("codex"), + plan: String(plan), + usageLimits: { + checkedAt, + windows: [{ ...weekly, id: "secondary", usedPercent: Number(usedPercent) }], + }, + })), + }, + ], + }, + }, + ], + ]), + ), + now, + ); + const [pooledWeekly] = pool?.windows ?? []; + // Pro 20x holds 20 of the pool's 21 capacity parts. + expect(pooledWeekly?.remainingPercent).toBe(5); + expect( + pooledWeekly?.resets.map(({ member, restoresPercent }) => [ + member.account.email, + restoresPercent, + ]), + ).toContainEqual(["pro@example.com", 95]); + }); + + it.each(["Enterprise", "constructor", "__proto__"])( + "uses equal pool weights when a plan is unknown (%s)", + (unknownPlan) => { + const accounts: LimitAccount[] = [ + { + key: "pro", + driver: ProviderDriverKind.make("codex"), + displayName: null, + email: undefined, + plan: "ChatGPT Pro 200 Subscription", + accentColor: undefined, + environments: [], + sourceLabel: null, + redeem: null, + limits: { checkedAt, windows: [{ ...weekly, usedPercent: 100 }] }, + }, + ]; + accounts.push({ + ...accounts[0]!, + key: "unknown", + plan: unknownPlan, + limits: { checkedAt, windows: [{ ...weekly, usedPercent: 0 }] }, + }); + const [pooledWeekly] = collectLimitPools(accounts, now)[0]!.windows; + expect(pooledWeekly?.remainingPercent).toBe(50); + expect(pooledWeekly?.resets[0]?.restoresPercent).toBe(50); + }, + ); + it("pools windows by id across accounts and orders resets by when they land", () => { const input = new Map([ [ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 06600a707da18..1da679aca55e2 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -726,6 +726,29 @@ function accountSortName(account: LimitAccount): string { return (account.displayName ?? account.email ?? account.key).toLowerCase(); } +const CODEX_PLAN_CAPACITY = new Map([ + ["ChatGPT Plus Subscription", 1], + ["ChatGPT Pro 100 Subscription", 5], + ["ChatGPT Pro 200 Subscription", 20], +]); +// Claude's published plan multipliers describe the session allowance only. +const CLAUDE_SESSION_CAPACITY = new Map([ + ["Pro", 1], + ["Max", 5], + ["Ultra", 20], +]); + +function planCapacity(account: LimitAccount, kind: ServerProviderUsageWindow["kind"]) { + if (!account.plan) return undefined; + if (account.driver === "codex" && (kind === "session" || kind === "weekly")) { + return CODEX_PLAN_CAPACITY.get(account.plan); + } + if (account.driver === "claudeAgent" && kind === "session") { + return CLAUDE_SESSION_CAPACITY.get(account.plan); + } + return undefined; +} + // Codex ids name slots, so pool by kind; other drivers' ids identify distinct allowances. function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string { return driver === "codex" ? window.kind : `${window.kind}:${window.id}`; @@ -798,15 +821,28 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L const memberByAccount = new Map(members.map((member) => [member.account.key, member])); const first = members[0]!.window; const id = members[0]!.account.driver === "codex" ? key : first.id; - const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // One unknown plan makes the pool incomparable by plan, so every member then counts once. + const capacities = members.map((m) => planCapacity(m.account, first.kind)); + const weights = capacities.every((capacity) => capacity !== undefined) + ? capacities + : members.map(() => 1); + const weightOf = new Map(members.map((m, index) => [m, weights[index]!])); + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); + const usedPercent = + members.reduce((sum, m) => sum + m.window.usedPercent * weightOf.get(m)!, 0) / totalWeight; // Judge pace only for windows with reset clocks; others add spend without elapsed time. const timed = members.flatMap((m) => { const share = elapsedShare(m.window, now); - return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + return share === null + ? [] + : [{ used: m.window.usedPercent, elapsed: share, weight: weightOf.get(m)! }]; }); - const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const timedWeight = timed.reduce((sum, t) => sum + t.weight, 0); + const timedUsed = timed.reduce((sum, t) => sum + t.used * t.weight, 0) / timedWeight; const meanElapsed = - timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + timed.length > 0 + ? timed.reduce((sum, t) => sum + t.elapsed * t.weight, 0) / timedWeight + : null; const resets = members .flatMap((member) => { const at = resetMillis(member.window); @@ -816,7 +852,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L { member, at, - restoresPercent: Math.round(member.refillPercent / members.length), + restoresPercent: Math.round( + (member.refillPercent * weightOf.get(member)!) / totalWeight, + ), }, ]; }) diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 9a97563e29de0..c282a1fb6687c 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -241,15 +241,18 @@ try { }, ); - NodeFS.rmSync(NodePath.resolve(tempRoot, "pnpm-lock.yaml"), { force: true }); - + // Release version bumps refresh the reviewed lockfile, preserving compatible transitive peers. NodeChildProcess.execFileSync("vp", ["install", "--lockfile-only", "--ignore-scripts"], { cwd: tempRoot, stdio: "inherit", }); const lockfile = NodeFS.readFileSync(NodePath.resolve(tempRoot, "pnpm-lock.yaml"), "utf8"); - assertContains(lockfile, "lockfileVersion:", "Expected pnpm-lock.yaml to be regenerated."); + assertContains( + lockfile, + "lockfileVersion:", + "Expected a release lockfile after refreshing package versions.", + ); for (const relativePath of [ "apps/server/package.json",