-
Notifications
You must be signed in to change notification settings - Fork 0
[WRONG BRANCH] fix(gui): bound optimistic active-account reconciliation #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Set<CodexAccountLoadObserver> | 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; | ||
|
Comment on lines
+219
to
+222
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve the optimistic active ID in When Line [219] consumes the stale read, Update As per path instructions, 🤖 Prompt for AI AgentsSource: Path instructions |
||
| } 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)); | ||
| }); | ||
|
Comment on lines
+485
to
+488
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Replace the fixed delay with deterministic synchronization.
Resolve a deferred mock response or wait for the expected GET count before asserting. 🤖 Prompt for AI Agents |
||
| // 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(); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Sequence all active-account mutations before updating the shared marker.
pendingActiveIdRefis shared by switch, pause, and bulk-pause operations, but these handlers can overlap. A late pause response can overwrite a newer switch result at Lines [394-397] or [480-483]. Conversely,setAccountPrioritycan clear a newer pause marker at Line [448].loadGenerationReforders refresh reads only. It does not order these PUT responses. The next read can therefore consume or accept the wrong active account.Use one shared active-mutation gate or queue, or attach a monotonic mutation revision and apply
activeIdand the marker only for the current revision. Add reverse-order tests for switch/pause and pause/priority responses.As per path instructions,
gui/**state changes must stay consistent with management API responses.Also applies to: 338-341, 394-397, 480-483
🤖 Prompt for AI Agents
Source: Path instructions