#6167 · Proactive OAuth rejection disables unexpired pool credentials
Trusted base: 1e80ca7b44bccd06c6da75ed9ca6ba497bd380f5.
Verdict: REPRODUCED · Root-cause confidence: high · Reproduction label: confirmed-repro
1. TL;DR
An OAuth refresh failure does not necessarily mean the currently held access token has expired. The request path nevertheless permanently excludes an account after a proactive HTTP 400 refresh rejection, without trying its still-unexpired access token. The background path instead resends the rejected refresh credential on every eligible usage tick, including after access-token expiry, without recording an account error. Both behaviors reproduced through the real Account Pooler hub and provider adapters for Claude and Codex; replacing an errored account’s stored credential also failed to restore it on the next background refresh. The same eight-case reproduction produced six expected regression failures and two passing transient-failure controls in two separate clean checkouts.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Proactive rejection removes an account while its access token is locally unexpired. | Verified | Both providers returned 429 with 180 seconds left, saved an account error, and made zero upstream inference calls. |
| Background usage refresh replays the same rejected refresh credential without setting an error. | Verified | Three calls at logical minutes 7, 12, and 17 sent identical refresh bodies. Error remained null at minute 17, seven minutes after expiry. |
| Replacing stored credentials does not automatically restore an errored account. | Verified | A replacement token was written through AccountStore.writeSecret; the next normal background refresh made no new token request and retained the old error. |
| Transient proactive failures retain the unexpired access token. | Verified | HTTP 503 controls returned 200, sent one upstream inference request, and left error null for each provider. |
| Error writes and successful-refresh error clears omit the accounts-changed callback. | Verified by source inspection only | markError writes QuotaStore directly; freshSecret’s success branch also writes directly. No runtime notification assertion was added. |
| The proactive rejection path does not recheck a concurrently replaced credential before recording the failure. | Verified by source inspection only | The catch in forward calls markError directly, unlike the distinct upstream-rejection check. |
| A real shared-grant race caused the reported long thread delays and repeated failures. | Not independently verified | No reporter logs, runtime, accounts, or credentials were accessed. Production durations and race timing are not evidence produced by this report. |
| The provider continues accepting the old access token after another holder refreshes. | Unverified | The access token was locally unexpired; no live provider grant was exercised. The fake inference endpoint accepts it, but the failing path never contacts that endpoint. |
3. Environment and isolation
- Target: public repository get-bb/bb; main and canonical remote main both resolved to the trusted base above.
- Linux 4.19.0-gvisor x86_64; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1.
- Two detached clean worktrees named BASE_CHECKOUT and VERIFY_CHECKOUT, each installed with the frozen lockfile and separately built through Turbo.
- Only repository production code and an original test/configuration written for this investigation ran. No issue-supplied test, linked branch, external tool, or dependency was installed.
- The test invokes createHub, the real Claude/Codex adapters, AccountStore, HubTokenStore, QuotaStore, and PoolAffinityStore. The existing Plugin SDK test host supplies KV and real temporary on-disk SQLite; the real plugin migrations initialize that database. The database is not mocked.
- Only the network transport is synthetic. All destinations use reserved .invalid names and the injected fetch rejects unexpected destinations. There are no listening ports, real provider processes, real grants, or user runtime data.
- Each case creates a fresh temporary data directory and cleans it, its SQLite connection, and the background service. The second checkout creates separate data directories again.
4. Minimal reproduction
- Prepare a trusted checkout at the recorded commit. This does not use a pull-request branch.
BASE=$(mktemp -d) git clone --single-branch --branch main https://github.com/get-bb/bb.git "$BASE/source" cd "$BASE/source" git checkout --detach 1e80ca7b44bccd06c6da75ed9ca6ba497bd380f5 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --filter=bb-plugin-account-pool... cd plugins/account-pool
- Create
plugins/account-pool/src/slopcop-6167.test.tswith the complete test below, andplugins/account-pool/vitest.slopcop-6167.config.tswith the configuration below. - From
plugins/account-pool, run the focused test:pnpm exec vitest run --config vitest.slopcop-6167.config.ts
The direct Vitest invocation is deliberate investigation: the package test script hardcodes its existing config; passing a second --config through Turbo is rejected by Vitest. Prerequisite builds still use Turbo.
- Repeat in a second newly created clean checkout at the same commit, with a new frozen install/build and fresh temporary case directories.
Expected and actual
| Scenario, for each provider | Expected | Actual on unchanged production code |
|---|---|---|
| Proactive HTTP 400 at logical minute 7; expiry at minute 10 | 200; no account error; one inference request | 429; OAuth rejection saved as error; zero inference requests |
| Background refresh at minutes 7, 12, and 17 | One attempt for the rejected refresh token; error recorded after expiry | Three identical rejected refresh requests; error remains null |
| Replace the stored secret after the request path saved an error | Background refresh tries the changed credential and clears the error on success | No replacement refresh attempted; original error persists |
| HTTP 503 control in the same proactive window | 200 and no account error | Passes for both providers |
The failures are the intended regression assertions against unfixed code, not build failures.
For each request case the observed JSON includes remainingSeconds: 180,
status: 429, refreshCalls: 1, and upstreamCalls: 0.
Complete original regression test
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { afterEach, describe, expect, it, vi } from "vitest";
import { accountPoolConfigSchema, type PoolProvider } from "./contracts.js";
import { createHub } from "./hub.js";
import {
AccountStore,
HubTokenStore,
PoolAffinityStore,
QUOTA_MIGRATIONS,
QuotaStore,
} from "./store.js";
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()?.();
});
async function fixture(provider: PoolProvider, rejectionStatus = 400) {
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "pool-6167-"));
const host = createFakePluginHost({ pluginId: "account-pool", dataDir });
const database = host.bb.storage.database();
host.bb.storage.migrate(database, QUOTA_MIGRATIONS);
const accounts = new AccountStore(host.bb.storage.kv, path.join(dataDir, "secrets"));
await accounts.initialize();
let time = 1_800_000_000_000;
const now = () => time;
const hubTokens = new HubTokenStore(path.join(dataDir, "hub-tokens"), now);
const quotas = new QuotaStore(database);
const refreshBodies: string[] = [];
let upstreamCalls = 0;
let usageCalls = 0;
let replacementAccepted = false;
const account = await accounts.add(
{
provider,
kind: "oauth",
label: "Synthetic account",
email: null,
accountUuid: null,
...(provider === "codex" ? { codexAccountId: "synthetic-codex-account" } : {}),
subscriptionType: null,
rateLimitTier: null,
enabled: true,
priority: 100,
},
{
kind: "oauth",
accessToken: "synthetic-access-original",
refreshToken: "synthetic-refresh-original",
expiresAt: time + 10 * 60_000,
},
);
const transport: typeof fetch = async (input, init) => {
const url = String(input);
if (url === "https://oauth.invalid/token") {
const body = String(init?.body);
refreshBodies.push(body);
if (replacementAccepted && body.includes("synthetic-refresh-replacement")) {
return Response.json({
access_token: "synthetic-access-renewed",
refresh_token: "synthetic-refresh-renewed",
expires_in: 3600,
});
}
return Response.json({ error: "invalid_grant" }, { status: rejectionStatus });
}
if (url === "https://usage.invalid/usage") {
usageCalls += 1;
return Response.json({});
}
if (url.startsWith("https://upstream.invalid/")) {
upstreamCalls += 1;
return Response.json({ id: "synthetic-response" });
}
throw new Error("Unexpected network destination in isolated reproduction.");
};
const hub = createHub({
accounts,
quotas,
affinity: new PoolAffinityStore(database),
hubTokens,
now,
fetch: transport,
getSettings: () => accountPoolConfigSchema.parse({
anthropicUpstreamBaseUrl: "https://upstream.invalid",
codexUpstreamBaseUrl: "https://upstream.invalid",
}),
refreshUrl: "https://oauth.invalid/token",
codexRefreshUrl: "https://oauth.invalid/token",
usageUrl: "https://usage.invalid/usage",
codexUsageUrl: "https://usage.invalid/usage",
});
const controller = new AbortController();
const running = hub.start(controller.signal);
cleanups.push(async () => {
controller.abort();
await running;
await host.harness.lifecycle.dispose();
await fs.rm(dataDir, { recursive: true, force: true });
});
await vi.waitFor(() => expect(usageCalls).toBe(1));
const token = await hubTokens.forHost("synthetic-host");
const request = async () => {
const route = provider === "claude" ? "/v1/messages" : "/responses";
const response = await hub.handle(new Request(`https://hub.invalid${route}`, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(provider === "claude"
? { model: "claude-sonnet-4", max_tokens: 1, messages: [{ role: "user", content: "ok" }] }
: { model: "gpt-5", input: "ok" }),
}), provider, route);
await response.text();
return response.status;
};
return {
hub,
quotas,
account,
refreshBodies,
request,
advance: (milliseconds: number) => { time += milliseconds; },
upstreamCalls: () => upstreamCalls,
replaceSecret: async () => {
replacementAccepted = true;
await accounts.writeSecret(account.id, {
kind: "oauth",
accessToken: "synthetic-access-replacement",
refreshToken: "synthetic-refresh-replacement",
expiresAt: time + 60_000,
});
},
};
}
describe.each(["claude", "codex"] as const)("proactive rejection: %s", (provider) => {
it("keeps a locally unexpired access token available after proactive rejection", async () => {
const pool = await fixture(provider);
pool.advance(7 * 60_000);
const status = await pool.request();
const error = pool.quotas.get(pool.account.id).error;
console.log(JSON.stringify({ scenario: "request", provider, status, error,
remainingSeconds: 180, refreshCalls: pool.refreshBodies.length, upstreamCalls: pool.upstreamCalls() }));
expect.soft(status).toBe(200);
expect.soft(error).toBeNull();
expect.soft(pool.upstreamCalls()).toBe(1);
});
it("does not replay a rejected refresh credential and records expiry in background", async () => {
const pool = await fixture(provider);
pool.advance(7 * 60_000);
await pool.hub.refreshUsage();
pool.advance(5 * 60_000);
await pool.hub.refreshUsage();
pool.advance(5 * 60_000);
await pool.hub.refreshUsage();
const error = pool.quotas.get(pool.account.id).error;
console.log(JSON.stringify({ scenario: "background", provider,
refreshCalls: pool.refreshBodies.length,
identicalRefreshBodies: new Set(pool.refreshBodies).size === 1,
expiredSecondsAgo: 420, error }));
expect.soft(pool.refreshBodies).toHaveLength(1);
expect.soft(error).not.toBeNull();
});
it("recovers on a background tick when the stored refresh credential changes", async () => {
const pool = await fixture(provider);
pool.advance(7 * 60_000);
await pool.request();
pool.advance(5 * 60_000);
await pool.replaceSecret();
await pool.hub.refreshUsage();
const error = pool.quotas.get(pool.account.id).error;
console.log(JSON.stringify({ scenario: "replacement", provider,
refreshCalls: pool.refreshBodies.length, error }));
expect.soft(pool.refreshBodies).toHaveLength(2);
expect.soft(error).toBeNull();
});
it("continues serving after a transient proactive refresh failure", async () => {
const pool = await fixture(provider, 503);
pool.advance(7 * 60_000);
expect(await pool.request()).toBe(200);
expect(pool.quotas.get(pool.account.id).error).toBeNull();
expect(pool.upstreamCalls()).toBe(1);
});
});
Focused test configuration
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
import { sharedWorkerProjects } from "../../vitest.shared.js";
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
projects: sharedWorkerProjects({
name: "slopcop-6167",
pkgDir: fileURLToPath(new URL(".", import.meta.url)),
include: ["src/slopcop-6167.test.ts"],
}),
},
});
The test and logs are embedded rather than attached as repository files, in accordance with the public reports repository policy. Raw local evidence remains outside the published repository.
5. Root cause
One permanent refresh outcome is handled inconsistently by its callers
The adapter’s proactive refresh window is 300 seconds. HTTP 400/401 from the token endpoint produces OAuthRefreshError, not TransientOAuthRefreshError. The test exercises HTTP 400; HTTP 401 classification is established by source inspection.
plugins/account-pool/src/provider-adapter.ts:121
121: export function oauthSecretDueForRefresh(
122: context: AdapterSecretContext,
123: ): Extract<AccountSecret, { kind: "oauth" }> | null {
124: const secret = context.secret;
125: if (
126: secret.kind !== "oauth" ||
127: (!context.forceRefresh &&
128: (secret.expiresAt === null ||
129: secret.expiresAt > context.now() + OAUTH_REFRESH_WINDOW_MS))
130: ) {
131: return null;
132: }
133: return secret;
134: }
plugins/account-pool/src/provider-adapter.ts:175
175: if (!response.ok) {
176: const retryAfterMs = retryAfterMilliseconds(
177: response.headers.get("retry-after"),
178: context.now(),
179: );
180: const detail = await oauthRefreshErrorDetail(response, timedOut);
181: const message = `OAuth refresh failed with HTTP ${response.status}.${detail}`;
182: if (response.status === 400 || response.status === 401) {
183: throw new OAuthRefreshError(message);
184: }
185: throw new TransientOAuthRefreshError(message, retryAfterMs);
186: }
freshSecret only implements token-keyed backoff and access-token fallback for transient failures. A permanent rejection clears existing backoff and throws immediately, without remembering the rejected refresh-token identity.
plugins/account-pool/src/hub.ts:1116
1116: } catch (error) {
1117: this.options.onOAuthRefresh(
1118: account.provider,
1119: account.id,
1120: "failed",
1121: `${error instanceof OAuthRefreshError ? error.message : "Stored credential refresh failed."} expiresAt=${secret.kind === "oauth" ? secret.expiresAt : null}, forced=${forceRefresh}.`,
1122: );
1123: if (
1124: !(error instanceof TransientOAuthRefreshError) ||
1125: secret.kind !== "oauth"
1126: ) {
1127: this.refreshBackoffs.delete(account.id);
1128: throw error;
1129: }
1130: const delayMs = Math.min(
1131: MAX_REFRESH_BACKOFF_MS,
1132: Math.max(
1133: backoff === undefined ? 1_000 : backoff.delayMs * 2,
1134: error.retryAfterMs,
1135: ),
1136: );
1137: this.refreshBackoffs.delete(account.id);
1138: this.refreshBackoffs.set(account.id, {
1139: kind: forceRefresh ? "rejected" : "proactive",
1140: accessToken: secret.accessToken,
1141: retryAt: this.options.now() + delayMs,
1142: delayMs,
1143: error,
1144: });
1145: while (this.refreshBackoffs.size > MAX_REFRESH_BACKOFFS) {
1146: const oldest = this.refreshBackoffs.keys().next();
1147: if (!oldest.done) this.refreshBackoffs.delete(oldest.value);
1148: }
1149: if (
1150: !forceRefresh &&
1151: secret.expiresAt !== null &&
1152: secret.expiresAt > this.options.now()
1153: ) {
1154: return secret;
1155: }
1156: throw error;
1157: }
forward catches that permanent failure and records an error before any upstream inference request. Selection excludes errored accounts, so one failing account gives 429; other accounts can instead cause failover or a later reset estimate.
plugins/account-pool/src/hub.ts:537
537: } catch (error) {
538: if (pacing !== null) {
539: this.releasePacing(selected.account.id, pacing);
540: pacing = null;
541: }
542: signal.throwIfAborted();
543: if (error instanceof TransientOAuthRefreshError) {
544: failure = { status: 503, message: error.message, headers: {} };
545: } else {
546: this.markError(selected.account.id, errorMessage(error));
547: }
548: continue;
plugins/account-pool/src/hub.ts:853
853: const available = accounts
854: .filter((account) => account.provider === provider && account.enabled)
855: .map((account) => ({
856: account,
857: quota: this.options.quotas.get(account.id),
858: }))
859: .filter(
860: ({ quota }) => quota.error === null && !isUsageRestricted(quota, now),
861: )
862: .filter(
863: ({ quota }) =>
864: !isSharedQuotaExhausted(quota, threshold, now) ||
865: hasExtraUsage(quota),
866: );
The normal background path invokes the same freshSecret but marks an error only for an explicitly requested recovery. Its outer catch otherwise drops the exception. With no rejected-token memory, each future eligible tick sends the same refresh token again.
plugins/account-pool/src/hub.ts:346
346: this.lastUsageRefreshAt.set(account.id, now);
347: const recover =
348: recoverError && this.options.quotas.get(account.id).error !== null;
349: const refresh = adapter
350: .refreshUsage({
351: account,
352: freshSecret: () =>
353: this.freshSecret(account, adapter, { kind: "normal" }, recover).catch(
354: (error: unknown) => {
355: if (recover && !(error instanceof TransientOAuthRefreshError))
356: this.markError(account.id, errorMessage(error));
357: throw error;
358: },
359: ),
360: accounts: this.options.accounts,
361: quotas: this.options.quotas,
362: fetch: this.options.fetch,
363: now: this.options.now,
364: })
365: .catch(() => undefined)
366: .finally(() => this.usageRefreshes.delete(account.id));
367: this.usageRefreshes.set(account.id, refresh);
368: return refresh;
369: }
The background loop’s five-minute schedule and public refreshUsage method select enabled OAuth accounts regardless of stored errors; our focused test directly invokes that same refreshUsage method with logical five-minute advances.
plugins/account-pool/src/hub.ts:164
164: async start(signal: AbortSignal): Promise<void> {
165: this.affinityBindings = this.options.affinity.loadBindings(
166: this.options.now() - AFFINITY_IDLE_TTL_MS,
167: MAX_AFFINITY_BINDINGS,
168: );
169: this.activeAccounts = this.options.affinity.loadActiveAccounts();
170: this.pacingByAccount.clear();
171: this.stopped = new AbortController();
172: this.accepting = true;
173: while (!signal.aborted) {
174: await this.refreshUsage();
175: await waitForDelay(DEFAULT_USAGE_REFRESH_INTERVAL_MS, signal);
176: }
177: await this.stop();
178: }
Once a request saves an error, freshSecret does reread the secret file but rejects the account before invoking the adapter. That explains why writing a replacement file does not by itself cause recovery.
plugins/account-pool/src/hub.ts:1074
1074: } 1075: const error = this.options.quotas.get(account.id).error; 1076: if (error !== null && !recover) throw new Error(error);
Finally, error transitions are not published through the callback wired to the account-pool realtime event. The following writes contain no onAccountsChanged invocation; this is a static finding, not a separate runtime reproduction.
plugins/account-pool/src/hub.ts:1105
1105: if (result.refreshed) {
1106: this.options.onOAuthRefresh(
1107: account.provider,
1108: account.id,
1109: "succeeded",
1110: `previousExpiresAt=${secret.kind === "oauth" ? secret.expiresAt : null}, expiresAt=${result.secret.kind === "oauth" ? result.secret.expiresAt : null}, forced=${forceRefresh}.`,
1111: );
1112: const quota = this.options.quotas.get(account.id);
1113: this.options.quotas.put({ ...quota, error: null });
1114: }
plugins/account-pool/src/hub.ts:1381
1381: private markError(accountId: string, message: string): void {
1382: const quota = this.options.quotas.get(accountId);
1383: this.options.quotas.put({ ...quota, error: message.slice(0, 1_000) });
1384: }
6. Proposed repair and automation safety gate
A reviewed repair should model rejected refresh credentials separately from access-token usability. Remember which refresh credential was rejected; do not retry it unchanged; keep non-forced use of a locally unexpired access token possible until expiry or actual upstream rejection. Before committing a terminal error, reread storage and reconcile a changed credential. A normal background tick should record terminal expiry consistently, and should only attempt recovery when storage now contains a different refresh credential. Publish account error transitions through the existing notification path.
Keep forced-refresh behavior and upstream 401/403 handling fail-closed, retain the existing transient backoff, and test refresh single-flight races, changed-secret detection, restart semantics, and event delivery before shipping. A successful fake endpoint does not prove real providers accept an access token after refresh-token rotation.
No automatic fix or pull request: this repair changes OAuth credential rejection and authentication recovery behavior. The rule explicitly excludes authentication changes from its simple-fix allowance. No production files were changed, no fix branch was pushed, and no unverified patch was published. No open pull request was linked to this issue in the checked GitHub connection/cross-reference metadata or open-PR search.
7. Verification: second clean run by the same agent
A second detached worktree was created directly at 1e80ca7b44bccd06c6da75ed9ca6ba497bd380f5.
Its working tree was clean before adding only the original reproduction test and config.
It received its own pnpm install --frozen-lockfile --prefer-offline and
pnpm exec turbo run build --filter=bb-plugin-account-pool..., then the exact focused command above.
No application state or temporary case directory was reused. This is a repeated check by the same agent, not independent verification.
Both runs exited 1 with 6 failed | 2 passed (8). All six scenario-observation JSON objects match exactly between runs. The two passing cases are the transient HTTP 503 controls. Both frozen installs and both scoped prerequisite builds succeeded.
Reproduction correction before the final two runs: the initial Codex fixture omitted its required account identifier and never reached the usage endpoint; the fixture was corrected to include a synthetic codexAccountId. Those setup failures are not counted as reproductions. No production code correction was necessary, and the second final run did not require a report correction.
8. Related work and limits
Trusted main history includes the prior upstream-rejection repair (merged PR #4385). That addresses inference endpoints rejecting freshly refreshed credentials, a different condition from the token endpoint’s permanent refresh rejection here. No linked pull-request code was checked out or executed. No external URLs supplied in the issue were fetched.
Untrusted-data handling: the report treated issue prose, code blocks, logs, and suggestions as claims only. The reproduction was designed from trusted repository code and uses neither the issue’s suggested test command nor a copied test.
Production race timing, the number and duration of past refresh replays, Provider Retry wake-up behavior, real provider access-token revocation semantics, and the exact three-thread delay were not reproduced. The verdict covers the independently observed pool behavior, not those unverified operational details.
9. Appendix: exact final focused-test output
Only checkout paths are normalized to BASE_CHECKOUT and VERIFY_CHECKOUT. No real credentials were used or printed.
First final run
RUN v4.1.1 BASE_CHECKOUT/plugins/account-pool
stdout | src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
{"scenario":"request","provider":"claude","status":429,"error":"OAuth refresh failed with HTTP 400. invalid_grant.","remainingSeconds":180,"refreshCalls":1,"upstreamCalls":0}
stdout | src/slopcop-6167.test.ts > proactive rejection: claude > does not replay a rejected refresh credential and records expiry in background
{"scenario":"background","provider":"claude","refreshCalls":3,"identicalRefreshBodies":true,"expiredSecondsAgo":420,"error":null}
stdout | src/slopcop-6167.test.ts > proactive rejection: claude > recovers on a background tick when the stored refresh credential changes
{"scenario":"replacement","provider":"claude","refreshCalls":1,"error":"OAuth refresh failed with HTTP 400. invalid_grant."}
stdout | src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
{"scenario":"request","provider":"codex","status":429,"error":"OAuth refresh failed with HTTP 400. invalid_grant.","remainingSeconds":180,"refreshCalls":1,"upstreamCalls":0}
stdout | src/slopcop-6167.test.ts > proactive rejection: codex > does not replay a rejected refresh credential and records expiry in background
{"scenario":"background","provider":"codex","refreshCalls":3,"identicalRefreshBodies":true,"expiredSecondsAgo":420,"error":null}
stdout | src/slopcop-6167.test.ts > proactive rejection: codex > recovers on a background tick when the stored refresh credential changes
{"scenario":"replacement","provider":"codex","refreshCalls":1,"error":"OAuth refresh failed with HTTP 400. invalid_grant."}
❯ |slopcop-6167| src/slopcop-6167.test.ts (8 tests | 6 failed) 1844ms
× keeps a locally unexpired access token available after proactive rejection 293ms
× does not replay a rejected refresh credential and records expiry in background 275ms
× recovers on a background tick when the stored refresh credential changes 80ms
× keeps a locally unexpired access token available after proactive rejection 120ms
× does not replay a rejected refresh credential and records expiry in background 140ms
× recovers on a background tick when the stored refresh credential changes 407ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
AssertionError: expected 429 to be 200 // Object.is equality
- Expected
+ Received
- 200
+ 429
❯ src/slopcop-6167.test.ts:150:25
148| console.log(JSON.stringify({ scenario: "request", provider, status…
149| remainingSeconds: 180, refreshCalls: pool.refreshBodies.length, …
150| expect.soft(status).toBe(200);
| ^
151| expect.soft(error).toBeNull();
152| expect.soft(pool.upstreamCalls()).toBe(1);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
AssertionError: expected 'OAuth refresh failed with HTTP 400. i…' to be null
- Expected:
null
+ Received:
"OAuth refresh failed with HTTP 400. invalid_grant."
❯ src/slopcop-6167.test.ts:151:24
149| remainingSeconds: 180, refreshCalls: pool.refreshBodies.length, …
150| expect.soft(status).toBe(200);
151| expect.soft(error).toBeNull();
| ^
152| expect.soft(pool.upstreamCalls()).toBe(1);
153| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
AssertionError: expected +0 to be 1 // Object.is equality
- Expected
+ Received
- 1
+ 0
❯ src/slopcop-6167.test.ts:152:39
150| expect.soft(status).toBe(200);
151| expect.soft(error).toBeNull();
152| expect.soft(pool.upstreamCalls()).toBe(1);
| ^
153| });
154|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > does not replay a rejected refresh credential and records expiry in background
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > does not replay a rejected refresh credential and records expiry in background
AssertionError: expected [ …(3) ] to have a length of 1 but got 3
- Expected
+ Received
- 1
+ 3
❯ src/slopcop-6167.test.ts:168:37
166| identicalRefreshBodies: new Set(pool.refreshBodies).size === 1,
167| expiredSecondsAgo: 420, error }));
168| expect.soft(pool.refreshBodies).toHaveLength(1);
| ^
169| expect.soft(error).not.toBeNull();
170| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > does not replay a rejected refresh credential and records expiry in background
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > does not replay a rejected refresh credential and records expiry in background
AssertionError: expected null not to be null
❯ src/slopcop-6167.test.ts:169:28
167| expiredSecondsAgo: 420, error }));
168| expect.soft(pool.refreshBodies).toHaveLength(1);
169| expect.soft(error).not.toBeNull();
| ^
170| });
171|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > recovers on a background tick when the stored refresh credential changes
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > recovers on a background tick when the stored refresh credential changes
AssertionError: expected [ Array(1) ] to have a length of 2 but got 1
- Expected
+ Received
- 2
+ 1
❯ src/slopcop-6167.test.ts:182:37
180| console.log(JSON.stringify({ scenario: "replacement", provider,
181| refreshCalls: pool.refreshBodies.length, error }));
182| expect.soft(pool.refreshBodies).toHaveLength(2);
| ^
183| expect.soft(error).toBeNull();
184| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > recovers on a background tick when the stored refresh credential changes
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > recovers on a background tick when the stored refresh credential changes
AssertionError: expected 'OAuth refresh failed with HTTP 400. i…' to be null
- Expected:
null
+ Received:
"OAuth refresh failed with HTTP 400. invalid_grant."
❯ src/slopcop-6167.test.ts:183:24
181| refreshCalls: pool.refreshBodies.length, error }));
182| expect.soft(pool.refreshBodies).toHaveLength(2);
183| expect.soft(error).toBeNull();
| ^
184| });
185|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/14]⎯
Test Files 1 failed (1)
Tests 6 failed | 2 passed (8)
Start at 12:56:45
Duration 2.79s (transform 613ms, setup 0ms, import 729ms, tests 1.84s, environment 0ms)
Second final run
RUN v4.1.1 VERIFY_CHECKOUT/plugins/account-pool
stdout | src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
{"scenario":"request","provider":"claude","status":429,"error":"OAuth refresh failed with HTTP 400. invalid_grant.","remainingSeconds":180,"refreshCalls":1,"upstreamCalls":0}
stdout | src/slopcop-6167.test.ts > proactive rejection: claude > does not replay a rejected refresh credential and records expiry in background
{"scenario":"background","provider":"claude","refreshCalls":3,"identicalRefreshBodies":true,"expiredSecondsAgo":420,"error":null}
stdout | src/slopcop-6167.test.ts > proactive rejection: claude > recovers on a background tick when the stored refresh credential changes
{"scenario":"replacement","provider":"claude","refreshCalls":1,"error":"OAuth refresh failed with HTTP 400. invalid_grant."}
stdout | src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
{"scenario":"request","provider":"codex","status":429,"error":"OAuth refresh failed with HTTP 400. invalid_grant.","remainingSeconds":180,"refreshCalls":1,"upstreamCalls":0}
stdout | src/slopcop-6167.test.ts > proactive rejection: codex > does not replay a rejected refresh credential and records expiry in background
{"scenario":"background","provider":"codex","refreshCalls":3,"identicalRefreshBodies":true,"expiredSecondsAgo":420,"error":null}
stdout | src/slopcop-6167.test.ts > proactive rejection: codex > recovers on a background tick when the stored refresh credential changes
{"scenario":"replacement","provider":"codex","refreshCalls":1,"error":"OAuth refresh failed with HTTP 400. invalid_grant."}
❯ |slopcop-6167| src/slopcop-6167.test.ts (8 tests | 6 failed) 512ms
× keeps a locally unexpired access token available after proactive rejection 85ms
× does not replay a rejected refresh credential and records expiry in background 63ms
× recovers on a background tick when the stored refresh credential changes 62ms
× keeps a locally unexpired access token available after proactive rejection 60ms
× does not replay a rejected refresh credential and records expiry in background 57ms
× recovers on a background tick when the stored refresh credential changes 62ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 6 ⎯⎯⎯⎯⎯⎯⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
AssertionError: expected 429 to be 200 // Object.is equality
- Expected
+ Received
- 200
+ 429
❯ src/slopcop-6167.test.ts:150:25
148| console.log(JSON.stringify({ scenario: "request", provider, status…
149| remainingSeconds: 180, refreshCalls: pool.refreshBodies.length, …
150| expect.soft(status).toBe(200);
| ^
151| expect.soft(error).toBeNull();
152| expect.soft(pool.upstreamCalls()).toBe(1);
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
AssertionError: expected 'OAuth refresh failed with HTTP 400. i…' to be null
- Expected:
null
+ Received:
"OAuth refresh failed with HTTP 400. invalid_grant."
❯ src/slopcop-6167.test.ts:151:24
149| remainingSeconds: 180, refreshCalls: pool.refreshBodies.length, …
150| expect.soft(status).toBe(200);
151| expect.soft(error).toBeNull();
| ^
152| expect.soft(pool.upstreamCalls()).toBe(1);
153| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > keeps a locally unexpired access token available after proactive rejection
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > keeps a locally unexpired access token available after proactive rejection
AssertionError: expected +0 to be 1 // Object.is equality
- Expected
+ Received
- 1
+ 0
❯ src/slopcop-6167.test.ts:152:39
150| expect.soft(status).toBe(200);
151| expect.soft(error).toBeNull();
152| expect.soft(pool.upstreamCalls()).toBe(1);
| ^
153| });
154|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > does not replay a rejected refresh credential and records expiry in background
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > does not replay a rejected refresh credential and records expiry in background
AssertionError: expected [ …(3) ] to have a length of 1 but got 3
- Expected
+ Received
- 1
+ 3
❯ src/slopcop-6167.test.ts:168:37
166| identicalRefreshBodies: new Set(pool.refreshBodies).size === 1,
167| expiredSecondsAgo: 420, error }));
168| expect.soft(pool.refreshBodies).toHaveLength(1);
| ^
169| expect.soft(error).not.toBeNull();
170| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > does not replay a rejected refresh credential and records expiry in background
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > does not replay a rejected refresh credential and records expiry in background
AssertionError: expected null not to be null
❯ src/slopcop-6167.test.ts:169:28
167| expiredSecondsAgo: 420, error }));
168| expect.soft(pool.refreshBodies).toHaveLength(1);
169| expect.soft(error).not.toBeNull();
| ^
170| });
171|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > recovers on a background tick when the stored refresh credential changes
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > recovers on a background tick when the stored refresh credential changes
AssertionError: expected [ Array(1) ] to have a length of 2 but got 1
- Expected
+ Received
- 2
+ 1
❯ src/slopcop-6167.test.ts:182:37
180| console.log(JSON.stringify({ scenario: "replacement", provider,
181| refreshCalls: pool.refreshBodies.length, error }));
182| expect.soft(pool.refreshBodies).toHaveLength(2);
| ^
183| expect.soft(error).toBeNull();
184| });
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/14]⎯
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: claude > recovers on a background tick when the stored refresh credential changes
FAIL |slopcop-6167| src/slopcop-6167.test.ts > proactive rejection: codex > recovers on a background tick when the stored refresh credential changes
AssertionError: expected 'OAuth refresh failed with HTTP 400. i…' to be null
- Expected:
null
+ Received:
"OAuth refresh failed with HTTP 400. invalid_grant."
❯ src/slopcop-6167.test.ts:183:24
181| refreshCalls: pool.refreshBodies.length, error }));
182| expect.soft(pool.refreshBodies).toHaveLength(2);
183| expect.soft(error).toBeNull();
| ^
184| });
185|
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/14]⎯
Test Files 1 failed (1)
Tests 6 failed | 2 passed (8)
Start at 12:57:34
Duration 1.43s (transform 674ms, setup 0ms, import 800ms, tests 512ms, environment 0ms)
Investigation commands consisted of trusted main fetch/revision checks; GitHub read-only metadata and classification reads; the authorized SlopCop classification writes; clean worktree creation; the frozen installs and scoped Turbo builds shown above; and the focused Vitest command. No app instance, live provider request, or production debugging command ran.