From 038c3f084f37886af29d9087595c39296bded098 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:05:09 +0100 Subject: [PATCH 1/6] fix(server): prevent lossy edits of non-UTF-8 workspace files (#36545) Co-authored-by: Julius Marminge (cherry picked from commit 5abf38367baf20ac658d00ee1e219c7a21dde71e) --- .../src/workspace/WorkspaceFileSystem.test.ts | 7 ++++-- .../src/workspace/WorkspaceFileSystem.ts | 24 +++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) 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 From 744da0157fd6f9c636ac0361c294e67b4a5c98e4 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:56:22 +0100 Subject: [PATCH 2/6] fix(server): deleted projects keep their worktree cleanup policy (#36649) Co-authored-by: Julius Marminge (cherry picked from commit 071ae4e9514c46b2607d40053b31007077fcd900) Adapted the import context to the fork while retaining its project brief and deletion behavior. --- .../project/ProjectService.deletion.test.ts | 48 ++++++++++++++++--- apps/server/src/project/ProjectService.ts | 10 +++- .../provider/ProjectSettingsOverrideSweep.ts | 39 +++++++++------ 3 files changed, 75 insertions(+), 22 deletions(-) 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/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, From 09b1fc6eb47c4eca551e419473030fcb94ecd68a Mon Sep 17 00:00:00 2001 From: Mahir Abbas <74065513+MahirAbbas@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:17:23 +0000 Subject: [PATCH 3/6] fix(server): a hung Codex app-server no longer blocks every later Codex run (#26555) Co-authored-by: Julius Marminge (cherry picked from commit f3b43efd9a4eae018d966b24210d4b66450a4b5d) Assert completion at the virtual deadline so regressions fail without waiting for a wall-clock timeout. --- .../Adapters/CodexAdapterV2.test.ts | 87 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 33 ++++++- 2 files changed, 116 insertions(+), 4 deletions(-) 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( From bd260015b07398d039df389fb757e73a7ad4edd9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 06:19:22 -0800 Subject: [PATCH 4/6] fix(server): Codex usage answering after three seconds is no longer dropped (#36189) (cherry picked from commit 8610b31ee8df481f8f53bfb9827fe0abe5d0fab2) Retained the fork's optional native usage and account discovery paths while removing the redundant usage timeout. --- apps/server/src/provider/CodexProvider.ts | 2 - .../src/provider/ProviderRegistry.test.ts | 81 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) 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/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; From 95b8f437851aba9733044dcd3584d53d6df38662 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:48:56 -0800 Subject: [PATCH 5/6] fix(shared): pooled limits weight each account by its plan size (#35427) (cherry picked from commit 0b21a82aa3761c6073a8a5282f4a598b9accd4be) Use Map lookups so every unknown plan label falls back to equal weights. Cover inherited object property names and preserve weekly exhaustion/refill handling. --- docs/user/usage.md | 3 + packages/shared/src/usageLimits.test.ts | 75 +++++++++++++++++++++++++ packages/shared/src/usageLimits.ts | 48 ++++++++++++++-- 3 files changed, 121 insertions(+), 5 deletions(-) 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, + ), }, ]; }) From 4cc57d17aca33379018206da4318edfd1ef0e9dc Mon Sep 17 00:00:00 2001 From: unlockdigital Date: Sat, 14 Nov 2026 16:11:54 -0600 Subject: [PATCH 6/6] fix(ci): retain the reviewed lockfile in release smoke Match the release workflow when refreshing package versions so fresh transitive dependency resolution cannot replace the compatible unenv peer set. Reproduced the failure before this change and passed release smoke with CI settings after it. --- scripts/release-smoke.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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",