From 53bb522526cddc97b3fe69ad2454b770a4a23eb8 Mon Sep 17 00:00:00 2001 From: Milos Date: Sun, 15 Mar 2026 22:38:40 +0100 Subject: [PATCH] fix: persist active account state after selection --- lib/accounts/manager.ts | 66 ++++++++++++++++++--------- test/account-manager-strategy.test.ts | 55 +++++++++++++++++++++- 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/lib/accounts/manager.ts b/lib/accounts/manager.ts index a5661e6..29bccfd 100644 --- a/lib/accounts/manager.ts +++ b/lib/accounts/manager.ts @@ -212,11 +212,6 @@ export class AccountManager { model, useRoundRobinCursor, ); - - if (account && this.config.accountSelectionStrategy === "hybrid") { - await this.saveToDisk(); - } - return account; } @@ -239,11 +234,11 @@ export class AccountManager { const account = this.accounts[index]; if (this.isAccountAvailable(account, model, now)) { - this.activeIndex = index; - if (useRoundRobinCursor) { - this.roundRobinCursor = (index + 1) % this.accounts.length; - } - account.lastUsed = now; + await this.updateSelectedAccount( + account, + now, + useRoundRobinCursor ? (index + 1) % this.accounts.length : undefined, + ); return account; } @@ -252,11 +247,13 @@ export class AccountManager { const fallback = this.getLeastRateLimitedAccount(model); if (fallback) { - this.activeIndex = fallback.index; - if (useRoundRobinCursor) { - this.roundRobinCursor = (fallback.index + 1) % this.accounts.length; - } - fallback.lastUsed = now; + await this.updateSelectedAccount( + fallback, + now, + useRoundRobinCursor + ? (fallback.index + 1) % this.accounts.length + : undefined, + ); } return fallback; } @@ -277,9 +274,18 @@ export class AccountManager { return; } - this.activeIndex = this.normalizeIndex(this.activeIndex); + const normalizedActiveIndex = this.normalizeIndex(this.activeIndex); + const normalizedRoundRobinCursor = this.normalizeIndex(this.roundRobinCursor); + const normalizedStateChanged = + normalizedActiveIndex !== this.activeIndex || + normalizedRoundRobinCursor !== this.roundRobinCursor; - this.roundRobinCursor = this.normalizeIndex(this.roundRobinCursor); + this.activeIndex = normalizedActiveIndex; + this.roundRobinCursor = normalizedRoundRobinCursor; + + if (normalizedStateChanged) { + await this.saveToDisk(); + } if (this.config.pidOffsetEnabled && this.accounts.length > 1) { const pidOffset = Math.abs(process.pid) % this.accounts.length; @@ -311,6 +317,26 @@ export class AccountManager { return true; } + private async updateSelectedAccount( + account: ManagedAccount, + now: number, + nextRoundRobinCursor?: number, + ): Promise { + const activeIndexChanged = this.activeIndex !== account.index; + const roundRobinCursorChanged = + nextRoundRobinCursor !== undefined && + this.roundRobinCursor !== nextRoundRobinCursor; + + this.activeIndex = account.index; + if (nextRoundRobinCursor !== undefined) { + this.roundRobinCursor = nextRoundRobinCursor; + } + account.lastUsed = now; + + if (activeIndexChanged || roundRobinCursorChanged) { + await this.saveToDisk(); + } + } private getLeastRateLimitedAccount(model?: string): ManagedAccount | null { if (this.accounts.length === 0) return null; @@ -490,8 +516,7 @@ export class AccountManager { for (const account of this.accounts) { if (excludeIndices.has(account.index)) continue; if (this.isAccountAvailable(account, model, now)) { - this.activeIndex = account.index; - account.lastUsed = now; + await this.updateSelectedAccount(account, now); return account; } } @@ -517,8 +542,7 @@ export class AccountManager { } if (bestAccount) { - this.activeIndex = bestAccount.index; - bestAccount.lastUsed = now; + await this.updateSelectedAccount(bestAccount, now); } return bestAccount; diff --git a/test/account-manager-strategy.test.ts b/test/account-manager-strategy.test.ts index 8307ddf..9e9995d 100644 --- a/test/account-manager-strategy.test.ts +++ b/test/account-manager-strategy.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, statSync } from "node:fs"; +import { mkdtempSync, readFileSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -19,6 +19,15 @@ async function createManager( }); } +function readAccountsStorage(home: string) { + return JSON.parse( + readFileSync(join(home, ".config", "opencode", "openai-accounts.json"), "utf-8"), + ) as { + activeAccountIndex: number; + roundRobinCursor?: number; + }; +} + describe("AccountManager strategy selection", () => { afterEach(() => { process.env.HOME = originalHome; @@ -61,6 +70,29 @@ describe("AccountManager strategy selection", () => { expect(pick4?.index).toBe(0); }); + it("persists live round-robin selection state to disk", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-rr-persisted-active-")); + const manager = await createManager(home, "round-robin"); + await manager.loadFromDisk(); + + await manager.addAccount("a@example.com", "rt-1"); + await manager.addAccount("b@example.com", "rt-2"); + await manager.addAccount("c@example.com", "rt-3"); + + expect((await manager.getNextAvailableAccount())?.index).toBe(0); + expect((await manager.getNextAvailableAccount())?.index).toBe(1); + + const stored = readAccountsStorage(home); + expect(stored.activeAccountIndex).toBe(1); + expect(stored.roundRobinCursor).toBe(2); + + const reloadedManager = await createManager(home, "round-robin"); + await reloadedManager.loadFromDisk(); + + expect(reloadedManager.getActiveAccount()?.index).toBe(1); + expect((await reloadedManager.getNextAvailableAccount())?.index).toBe(2); + }); + it("rotates initial account across sessions in hybrid mode", async () => { const home = mkdtempSync(join(tmpdir(), "strategy-hybrid-")); @@ -101,6 +133,27 @@ describe("AccountManager strategy selection", () => { expect(third?.index).toBe(1); }); + it("persists active account after exclusion-based failover", async () => { + const home = mkdtempSync(join(tmpdir(), "strategy-sticky-persisted-failover-")); + const manager = await createManager(home, "sticky"); + await manager.loadFromDisk(); + + await manager.addAccount("a@example.com", "rt-1"); + await manager.addAccount("b@example.com", "rt-2"); + + expect((await manager.getNextAvailableAccount("gpt-5.2-codex"))?.index).toBe(0); + expect( + (await manager.getNextAvailableAccountExcluding(new Set([0]), "gpt-5.2-codex"))?.index, + ).toBe(1); + + const stored = readAccountsStorage(home); + expect(stored.activeAccountIndex).toBe(1); + + const reloadedManager = await createManager(home, "sticky"); + await reloadedManager.loadFromDisk(); + + expect(reloadedManager.getActiveAccount()?.index).toBe(1); + }); it("skips rate-limited accounts and keeps round-robin progression", async () => { const home = mkdtempSync(join(tmpdir(), "strategy-rr-failover-")); const manager = await createManager(home, "round-robin");