← reports

#6167 · Proactive OAuth rejection disables unexpired pool credentials

BugPriority: Medium Effort: Mediumproviders Issue #61672026-10-23

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

ClaimFindingEvidence
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

4. Minimal reproduction

  1. 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
  2. Create plugins/account-pool/src/slopcop-6167.test.ts with the complete test below, and plugins/account-pool/vitest.slopcop-6167.config.ts with the configuration below.
  3. 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.

  4. 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 providerExpectedActual on unchanged production code
Proactive HTTP 400 at logical minute 7; expiry at minute 10200; no account error; one inference request429; OAuth rejection saved as error; zero inference requests
Background refresh at minutes 7, 12, and 17One attempt for the rejected refresh token; error recorded after expiryThree identical rejected refresh requests; error remains null
Replace the stored secret after the request path saved an errorBackground refresh tries the changed credential and clears the error on successNo replacement refresh attempted; original error persists
HTTP 503 control in the same proactive window200 and no account errorPasses 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.