Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Auth } from "@codex-ai/sdk";
import { saveAccountsWithRetry } from "./codex-manager/forecast-report-shared.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

decouple the retry helper from the codex-manager namespace.

lib/accounts.ts:2 imports a storage-persistence utility from lib/codex-manager/forecast-report-shared.ts. move saveAccountsWithRetry to a neutral storage/shared module so account core logic does not depend on a codex-manager feature namespace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/accounts.ts` at line 2, The accounts module currently imports
saveAccountsWithRetry from the codex-manager namespace; extract
saveAccountsWithRetry into a neutral shared storage module (e.g., create a new
storage/shared or utils/storage module) and export it from there, then update
lib/accounts.ts to import saveAccountsWithRetry from the new shared module
instead of codex-manager; also update any codex-manager files that used the old
location to import the helper from the new shared module and ensure the moved
function's tests/exports are updated accordingly so account core logic no longer
depends on the codex-manager namespace.

import { createLogger } from "./logger.js";
import {
loadAccounts,
Expand Down Expand Up @@ -286,7 +287,7 @@ export class AccountManager {
const sourceOfTruthStorage = synced.storage ?? stored;
if (synced.changed && sourceOfTruthStorage) {
try {
await saveAccounts(sourceOfTruthStorage);
await saveAccountsWithRetry(sourceOfTruthStorage, saveAccounts);
} catch (error) {
log.debug("Failed to persist Codex CLI source-of-truth sync", {
error: String(error),
Expand Down
5 changes: 3 additions & 2 deletions lib/codex-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "./codex-manager/commands/best.js";
import { runCheckCommand } from "./codex-manager/commands/check.js";
import { runConfigExplainCommand } from "./codex-manager/commands/config-explain.js";
import { saveAccountsWithRetry } from "./codex-manager/forecast-report-shared.js";
import { runDebugBundleCommand } from "./codex-manager/commands/debug-bundle.js";
import {
parseWhySelectedArgs,
Expand Down Expand Up @@ -2358,7 +2359,7 @@ async function runHealthCheck(options: HealthCheckOptions = {}): Promise<void> {
}

if (changed) {
await saveAccounts(storage);
await saveAccountsWithRetry(storage, saveAccounts);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (
Expand Down Expand Up @@ -3207,7 +3208,7 @@ async function persistAndSyncSelectedAccount({

account.lastUsed = switchNow;
account.lastSwitchReason = switchReason;
await saveAccounts(storage);
await saveAccountsWithRetry(storage, saveAccounts);

const synced = await setCodexCliActiveSelection({
accountId: account.accountId,
Expand Down
47 changes: 47 additions & 0 deletions test/accounts-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,56 @@ describe("accounts edge branches", () => {
const manager = await AccountManager.loadFromDisk();

expect(manager.getAccountCount()).toBe(1);
// Non-retryable error (no errno code) → single attempt, then debug-logged.
expect(mockSaveAccounts).toHaveBeenCalledTimes(1);
});

it("loadFromDisk retries source-of-truth persist on transient EBUSY", async () => {
const stored = buildStored([
buildStoredAccount({ refreshToken: "stored-1" }),
]);
mockLoadAccounts.mockResolvedValue(stored);
mockSyncAccountStorageFromCodexCli.mockResolvedValue({
storage: stored,
changed: true,
});
const ebusy = Object.assign(new Error("file busy"), { code: "EBUSY" });
mockSaveAccounts.mockRejectedValueOnce(ebusy);
mockSaveAccounts.mockResolvedValueOnce(undefined);
mockLoadCodexCliState.mockResolvedValue({ accounts: [] });

const { AccountManager } = await importAccountsModule();
const manager = await AccountManager.loadFromDisk();

expect(manager.getAccountCount()).toBe(1);
// First attempt EBUSY, second succeeds — retry helper should have called twice.
expect(mockSaveAccounts).toHaveBeenCalledTimes(2);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("loadFromDisk exhausts retries on persistent EPERM and continues", async () => {
const stored = buildStored([
buildStoredAccount({ refreshToken: "stored-1" }),
]);
mockLoadAccounts.mockResolvedValue(stored);
mockSyncAccountStorageFromCodexCli.mockResolvedValue({
storage: stored,
changed: true,
});
const eperm = Object.assign(new Error("permission denied"), {
code: "EPERM",
});
mockSaveAccounts.mockRejectedValue(eperm);
mockLoadCodexCliState.mockResolvedValue({ accounts: [] });

const { AccountManager } = await importAccountsModule();
const manager = await AccountManager.loadFromDisk();

expect(manager.getAccountCount()).toBe(1);
// Persistent EPERM exhausts the retry budget (initial + 3 retries = 4
// attempts) before lib/accounts.ts catches and continues.
expect(mockSaveAccounts).toHaveBeenCalledTimes(4);
});

it("hydrates from Codex CLI cache and catches save failures", async () => {
const now = Date.now();
const stored = buildStored([
Expand Down
120 changes: 117 additions & 3 deletions test/codex-manager-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3215,9 +3215,10 @@ describe("codex manager cli commands", () => {
],
});
loadQuotaCacheMock.mockResolvedValueOnce(originalQuotaCache);
saveAccountsMock.mockRejectedValueOnce(
makeErrnoError("save failed", "EBUSY"),
);
// Use mockRejectedValue (unbounded) so saveAccountsWithRetry's EBUSY retries
// also fail; the test asserts the rejection path and that we never silently
// drop the error.
saveAccountsMock.mockRejectedValue(makeErrnoError("save failed", "EBUSY"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");

await expect(runCodexMultiAuthCli(["auth", "check"])).rejects.toMatchObject(
Expand All @@ -3226,6 +3227,9 @@ describe("codex manager cli commands", () => {
message: "save failed",
},
);
// saveAccountsWithRetry must have retried beyond a single attempt; if a
// regression replaces it with a raw saveAccounts call, this drops to 1.
expect(saveAccountsMock.mock.calls.length).toBeGreaterThan(1);
expect(originalQuotaCache).toEqual({
byAccountId: {},
byEmail: {},
Expand Down Expand Up @@ -3541,6 +3545,116 @@ describe("codex manager cli commands", () => {
);
});

it("retries saveAccounts on transient EBUSY when persisting best-account switch", async () => {
// Regression for the saveAccountsWithRetry call in
// persistAndSyncSelectedAccount (lib/codex-manager.ts:3211): a single
// EBUSY must NOT abort the switch — the retry helper should recover.
const now = Date.now();
let storageState = {
version: 3,
activeIndex: 0,
activeIndexByFamily: { codex: 0 },
accounts: [
{
email: "current@example.com",
accountId: "acc_current",
refreshToken: "refresh-current",
accessToken: "access-current",
expiresAt: now + 3_600_000,
addedAt: now - 2_000,
lastUsed: now - 2_000,
coolingDownUntil: now + 60_000,
enabled: true,
},
{
email: "next@example.com",
accountId: "acc_next",
refreshToken: "refresh-next",
accessToken: "access-next",
expiresAt: now + 3_600_000,
addedAt: now - 1_000,
lastUsed: now - 1_000,
enabled: true,
},
],
};
loadAccountsMock.mockImplementation(async () =>
structuredClone(storageState),
);
// First save attempt rejects with EBUSY; second succeeds. Without the
// retry wrapper this collapses to a single attempt and the switch fails.
saveAccountsMock.mockRejectedValueOnce(makeErrnoError("busy", "EBUSY"));
saveAccountsMock.mockImplementationOnce(async (nextStorage) => {
storageState = structuredClone(nextStorage);
});
setCodexCliActiveSelectionMock.mockResolvedValueOnce(true);

const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");

const exitCode = await runCodexMultiAuthCli(["auth", "best"]);

expect(exitCode).toBe(0);
expect(saveAccountsMock).toHaveBeenCalledTimes(2);
expect(storageState.activeIndex).toBe(1);
expect(setCodexCliActiveSelectionMock).toHaveBeenCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("Switched to best account 2"),
);
});

it("propagates persistent EBUSY from persistAndSyncSelectedAccount after retry exhaustion", async () => {
// Regression for the saveAccountsWithRetry call in
// persistAndSyncSelectedAccount: when EBUSY persists past the retry
// budget (initial + 3 retries = 4 attempts), the error must propagate
// rather than be silently swallowed, and codex-cli must not be told
// about a switch that did not actually persist.
const now = Date.now();
const storageState = {
version: 3,
activeIndex: 0,
activeIndexByFamily: { codex: 0 },
accounts: [
{
email: "current@example.com",
accountId: "acc_current",
refreshToken: "refresh-current",
accessToken: "access-current",
expiresAt: now + 3_600_000,
addedAt: now - 2_000,
lastUsed: now - 2_000,
coolingDownUntil: now + 60_000,
enabled: true,
},
{
email: "next@example.com",
accountId: "acc_next",
refreshToken: "refresh-next",
accessToken: "access-next",
expiresAt: now + 3_600_000,
addedAt: now - 1_000,
lastUsed: now - 1_000,
enabled: true,
},
],
};
loadAccountsMock.mockImplementation(async () =>
structuredClone(storageState),
);
saveAccountsMock.mockRejectedValue(makeErrnoError("busy", "EBUSY"));

const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js");

await expect(runCodexMultiAuthCli(["auth", "best"])).rejects.toMatchObject(
{
code: "EBUSY",
},
);
// Initial attempt + 3 retries = 4 calls before throwing.
expect(saveAccountsMock).toHaveBeenCalledTimes(4);
expect(setCodexCliActiveSelectionMock).not.toHaveBeenCalled();
});

it("parses --model=value for live best selection", async () => {
const now = Date.now();
loadAccountsMock.mockResolvedValueOnce({
Expand Down