From a4ac1e4688c7afb93d8b58582e196c6b949a2dac Mon Sep 17 00:00:00 2001 From: Remus Mate Date: Wed, 18 Feb 2026 00:27:52 +0200 Subject: [PATCH 1/2] fix: reduce excessive file writes from account state updates - Remove unconditional requestSaveToDisk() on every API request (line 1702) - Add snapshot deduplication to skip no-op writes - Increase debounce from 1s to 5s for rate-limit storm resilience - Move save trigger to after markAccountUsed() where lastUsed is persisted Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019c6dae-36aa-7524-849b-d8c515ba0cd1 --- src/plugin.ts | 5 ++-- src/plugin/accounts.test.ts | 59 ++++++++++++++++++++++++++----------- src/plugin/accounts.ts | 26 +++++++++++----- 3 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index cad8209..51af1c6 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1699,8 +1699,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( accountManager.markToastShown(account.index); } - accountManager.requestSaveToDisk(); - let authRecord = accountManager.toAuthDetails(account); if (accessTokenExpired(authRecord)) { @@ -2330,7 +2328,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( account.consecutiveFailures = 0; getHealthTracker().recordSuccess(account.index); accountManager.markAccountUsed(account.index); - + accountManager.requestSaveToDisk(); + void triggerAsyncQuotaRefreshForAccount( accountManager, account.index, diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index 24a02a5..b4e66a9 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts"; -import type { AccountStorageV4 } from "./storage"; +import { saveAccounts, type AccountStorageV4 } from "./storage"; import type { OAuthAuthDetails } from "./types"; // Mock storage to prevent test data from leaking to real config files @@ -1069,6 +1069,8 @@ describe("AccountManager", () => { describe("Issue #174: saveToDisk throttling", () => { it("requestSaveToDisk coalesces multiple calls into one write", async () => { vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); const stored: AccountStorageV4 = { version: 4, @@ -1079,23 +1081,22 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); manager.requestSaveToDisk(); manager.requestSaveToDisk(); - expect(saveSpy).not.toHaveBeenCalled(); + expect(saveMock).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(5500); - expect(saveSpy).toHaveBeenCalledTimes(1); - - saveSpy.mockRestore(); + expect(saveMock).toHaveBeenCalledTimes(1); }); it("flushSaveToDisk waits for pending save to complete", async () => { vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); const stored: AccountStorageV4 = { version: 4, @@ -1106,22 +1107,21 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); const flushPromise = manager.flushSaveToDisk(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(5500); await flushPromise; - expect(saveSpy).toHaveBeenCalledTimes(1); - - saveSpy.mockRestore(); + expect(saveMock).toHaveBeenCalledTimes(1); }); it("does not save again if no new requestSaveToDisk after flush", async () => { vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); const stored: AccountStorageV4 = { version: 4, @@ -1132,18 +1132,41 @@ describe("AccountManager", () => { }; const manager = new AccountManager(undefined, stored); - const saveSpy = vi.spyOn(manager, "saveToDisk").mockResolvedValue(); manager.requestSaveToDisk(); - await vi.advanceTimersByTimeAsync(1500); + await vi.advanceTimersByTimeAsync(5500); + + expect(saveMock).toHaveBeenCalledTimes(1); - expect(saveSpy).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(6000); + + expect(saveMock).toHaveBeenCalledTimes(1); + }); + + it("skips write when account state has not changed", async () => { + vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); + + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; - await vi.advanceTimersByTimeAsync(3000); + const manager = new AccountManager(undefined, stored); - expect(saveSpy).toHaveBeenCalledTimes(1); + // First save — should write + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(1); - saveSpy.mockRestore(); + // Second save with no state change — should skip + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index e86ed5f..f6eff99 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -312,6 +312,7 @@ export class AccountManager { private savePending = false; private saveTimeout: ReturnType | null = null; private savePromiseResolvers: Array<() => void> = []; + private lastSavedSnapshot: string | null = null; static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise { const stored = await loadAccounts(); @@ -973,11 +974,11 @@ export class AccountManager { return [...this.accounts]; } - async saveToDisk(): Promise { + private buildStorageState(): AccountStorageV4 { const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude); const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini); - - const storage: AccountStorageV4 = { + + return { version: 4, accounts: this.accounts.map((a) => ({ email: a.email, @@ -1005,9 +1006,13 @@ export class AccountManager { claude: claudeIndex, gemini: geminiIndex, }, - }; + } + } - await saveAccounts(storage); + async saveToDisk(): Promise { + const state = this.buildStorageState(); + await saveAccounts(state); + this.lastSavedSnapshot = JSON.stringify(state); } requestSaveToDisk(): void { @@ -1017,7 +1022,7 @@ export class AccountManager { this.savePending = true; this.saveTimeout = setTimeout(() => { void this.executeSave(); - }, 1000); + }, 5000); } async flushSaveToDisk(): Promise { @@ -1032,9 +1037,14 @@ export class AccountManager { private async executeSave(): Promise { this.savePending = false; this.saveTimeout = null; - + try { - await this.saveToDisk(); + const state = this.buildStorageState(); + const snapshot = JSON.stringify(state); + if (snapshot !== this.lastSavedSnapshot) { + await saveAccounts(state); + this.lastSavedSnapshot = snapshot; + } } catch { // best-effort persistence; avoid unhandled rejection from timer-driven saves } finally { From f7d1145a7b464252c18d7faf7c59255b05fb3da4 Mon Sep 17 00:00:00 2001 From: Remus Mate Date: Wed, 18 Feb 2026 01:12:04 +0200 Subject: [PATCH 2/2] address review: shallow-copy rateLimitResetTimes in buildStorageState, add test for state-change write Amp-Thread-ID: https://ampcode.com/threads/T-019c6dae-36aa-7524-849b-d8c515ba0cd1 Co-authored-by: Amp --- src/plugin/accounts.test.ts | 27 +++++++++++++++++++++++++++ src/plugin/accounts.ts | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index b4e66a9..a0c6813 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1143,6 +1143,33 @@ describe("AccountManager", () => { expect(saveMock).toHaveBeenCalledTimes(1); }); + it("writes when account state changes between debounced saves", async () => { + vi.useFakeTimers(); + const saveMock = vi.mocked(saveAccounts); + saveMock.mockClear(); + + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + + // First save + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(1); + + // Mutate state, then request save — should write again + manager.markAccountUsed(0); + manager.requestSaveToDisk(); + await vi.advanceTimersByTimeAsync(5500); + expect(saveMock).toHaveBeenCalledTimes(2); + }); + it("skips write when account state has not changed", async () => { vi.useFakeTimers(); const saveMock = vi.mocked(saveAccounts); diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index f6eff99..f91e7d1 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -989,7 +989,7 @@ export class AccountManager { lastUsed: a.lastUsed, enabled: a.enabled, lastSwitchReason: a.lastSwitchReason, - rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined, + rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? { ...a.rateLimitResetTimes } : undefined, coolingDownUntil: a.coolingDownUntil, cooldownReason: a.cooldownReason, fingerprint: a.fingerprint,