From eff710b21f9031bb85adb1b96b113ebd51cfb1cc Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 9 Aug 2026 15:30:19 +0900 Subject: [PATCH] fix(gui): bound optimistic active account reconciliation --- gui/src/hooks/useCodexAccountPool.ts | 23 ++++++++++++++----- .../codex-account-pool-behaviour.test.tsx | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index ec846ab2f3..3815e6f2b8 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -133,7 +133,7 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou const loadGenerationRef = useRef(0); // Set by switchAccount so a background load already in flight cannot roll the active // id back to a value the server had not yet committed when that request was issued. - const pendingActiveIdRef = useRef<{ id: string | null } | null>(null); + const pendingActiveIdRef = useRef<{ id: string | null; staleReadsRemaining: number } | null>(null); const observersRef = useRef | null>(null); if (observersRef.current === null) observersRef.current = new Set(); // Last /active payload an actual read returned. Surfaces that mount after a @@ -216,8 +216,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou if (loadGenerationRef.current === generation) { const serverActiveId = active.activeCodexAccountId ?? null; const pending = pendingActiveIdRef.current; - if (pending && serverActiveId !== pending.id) { - // Stale read: keep the accepted value and let the next load reconcile. + if (pending && pending.staleReadsRemaining > 0 && serverActiveId !== pending.id) { + // Allow one eventually-consistent response to preserve the accepted value, + // but ensure a repeated mismatch can reconcile legitimate routing changes. + pending.staleReadsRemaining -= 1; } else { pendingActiveIdRef.current = null; nextActiveId = serverActiveId; @@ -333,7 +335,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou if (!response.ok) throw new Error("account switch failed"); const result = await response.json().catch(() => ({})) as { activeCodexAccountId?: string | null }; const selectedId = result.activeCodexAccountId ?? id; - pendingActiveIdRef.current = { id: selectedId ?? null }; + pendingActiveIdRef.current = { + id: selectedId ?? null, + staleReadsRemaining: 1, + }; setActiveId(selectedId ?? null); // A manual selection pins its target until the account drains or routing moves off // it. The badge follows the id, not /active's `pinned` boolean, so a same-tier @@ -386,7 +391,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou ))); if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { const nextActiveId = result.activeCodexAccountId ?? null; - pendingActiveIdRef.current = { id: nextActiveId }; + pendingActiveIdRef.current = { + id: nextActiveId, + staleReadsRemaining: 1, + }; setActiveId(nextActiveId); } // Deliberately NOT cross-gated against the switch and order writes, even though @@ -469,7 +477,10 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou ))); if (Object.prototype.hasOwnProperty.call(result, "activeCodexAccountId")) { const nextActiveId = result.activeCodexAccountId ?? null; - pendingActiveIdRef.current = { id: nextActiveId }; + pendingActiveIdRef.current = { + id: nextActiveId, + staleReadsRemaining: 1, + }; setActiveId(nextActiveId); } // Conditional for the same reason as the single-account pause above: clearing diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 8d67cd146f..d22275a3c1 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -471,6 +471,29 @@ test("an accepted manual switch moves the pin before reconciliation lands", asyn }); }); +test("a post-switch read accepts a newer server-side active account", async () => { + accounts = [ + { id: "a1", email: "main", isMain: true, paused: false, priority: 0, hasCredential: true, quota: null }, + { id: "a2", email: "selected", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null }, + { id: "a3", email: "failover", isMain: false, paused: false, priority: 0, hasCredential: true, quota: null }, + ]; + const seen = await mountController(); + + // The PUT accepts a2, but routing legitimately moves to a3 before the + // reconciliation read. That fresh response must retire the optimistic marker. + activeGetId = "a3"; + await act(async () => { + expect(await seen.current!.switchAccount("a2")).toEqual({ ok: true, activeId: "a2" }); + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + // One mismatch may be the eventually-consistent response the optimistic marker + // exists to absorb. + expect(seen.current!.activeId).toBe("a2"); + + await act(async () => { await seen.current!.load(); }); + expect(seen.current!.activeId).toBe("a3"); +}); + test("the main sentinel writes through to its distinct account row", async () => { const seen = await mountController();