Skip to content

Commit a9ec6c4

Browse files
authored
merge: land remaining removeAccount retarget signal fix from PR #420
Merge the remaining HI-04 follow-up PR after the rollup landed. This change was left out of the rollup merge and still carries a valid fix: when removeAccount() silently retargets the active pointer onto a successor, that successor now gets `lastSwitchReason = "rotation"` so downstream consumers can tell the pool selected it. Tests included on the branch cover: - removed-current successor stamp - no stamp when all remaining peers are disabled - sole enabled successor stamp - non-current removal does not stamp (via follow-up review fix)
2 parents ad486c4 + 07c2032 commit a9ec6c4

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

lib/accounts.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1375,6 +1375,14 @@ export class AccountManager {
13751375
return true;
13761376
}
13771377

1378+
// Track successor accounts that we explicitly retarget onto because the
1379+
// removed account was the caller's "current" for a given family. We
1380+
// stamp each such successor with lastSwitchReason="rotation" so the
1381+
// retarget is auditable instead of silently carrying whatever stale
1382+
// reason the successor already had (HI-04). De-duplicated across
1383+
// families because lastSwitchReason is per-account, not per-family.
1384+
const retargetedSuccessors = new Set<number>();
1385+
13781386
for (const family of MODEL_FAMILIES) {
13791387
// Cursor: shift down if it was past the removed index, then normalize
13801388
// into [0, length). If the cursor was pointing AT the removed slot
@@ -1397,18 +1405,37 @@ export class AccountManager {
13971405
// AT the removed slot (or is now dangling off the end after
13981406
// splice), advance to the next enabled account instead of
13991407
// defaulting to -1. Fall back to -1 only when every remaining
1400-
// account is disabled or the pool is empty.
1408+
// account is disabled or the pool is empty. When we retarget off
1409+
// the removed slot onto a different account, record the successor
1410+
// so we can explicitly signal the retarget via lastSwitchReason.
14011411
let active = priorActive[family];
1412+
const activeWasRemoved = active === idx;
14021413
if (active > idx) {
14031414
active -= 1;
1404-
} else if (active === idx) {
1415+
} else if (activeWasRemoved) {
14051416
// Same numeric position now hosts the successor account.
14061417
active = this.findNextEnabled(Math.min(idx, this.accounts.length - 1));
14071418
}
14081419
if (active >= this.accounts.length) {
14091420
active = this.findNextEnabled(0);
14101421
}
14111422
this.currentAccountIndexByFamily[family] = active;
1423+
if (activeWasRemoved && active >= 0 && active < this.accounts.length) {
1424+
retargetedSuccessors.add(active);
1425+
}
1426+
}
1427+
1428+
// Stamp retarget signal on each successor account that replaced a
1429+
// removed "current" pointer. This mirrors the existing rotation
1430+
// convention used by setActiveIndex / markSwitched, so downstream
1431+
// callers observing lastSwitchReason see a clear audit trail that the
1432+
// pool re-chose this account rather than the user selecting it
1433+
// themselves.
1434+
for (const successorIdx of retargetedSuccessors) {
1435+
const successor = this.accounts[successorIdx];
1436+
if (successor) {
1437+
successor.lastSwitchReason = "rotation";
1438+
}
14121439
}
14131440

14141441
return true;

test/accounts.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3078,6 +3078,122 @@ describe("AccountManager", () => {
30783078
});
30793079
});
30803080

3081+
describe("removed-current retarget signal (HI-04)", () => {
3082+
// PR #413 hardened removeAccount() to avoid the pointer-dangle
3083+
// default-to-minus-one behavior, but it silently retargeted the
3084+
// current pointer onto a different account when the removed
3085+
// account WAS the current one. HI-04 flagged that silent
3086+
// retarget: callers observing getCurrentAccountForFamily()
3087+
// receive an account they never selected with no audit trail.
3088+
//
3089+
// The fix stamps lastSwitchReason="rotation" on the successor
3090+
// whenever removeAccount() retargets off the removed slot. This
3091+
// mirrors the convention already used by setActiveIndex() and
3092+
// markSwitched() for pool-driven selection events.
3093+
3094+
it("stamps lastSwitchReason='rotation' on successor when current is removed", () => {
3095+
const now = Date.now();
3096+
const stored = {
3097+
version: 3 as const,
3098+
activeIndex: 1,
3099+
activeIndexByFamily: { codex: 1 },
3100+
accounts: [
3101+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, lastSwitchReason: "initial" as const },
3102+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, lastSwitchReason: "initial" as const },
3103+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, lastSwitchReason: "initial" as const },
3104+
],
3105+
};
3106+
3107+
const manager = new AccountManager(undefined, stored as never);
3108+
const active = manager.getCurrentAccountForFamily("codex");
3109+
expect(active?.refreshToken).toBe("token-2");
3110+
3111+
// Remove the currently active (middle) account. The successor
3112+
// (token-3) shifts into index 1 and becomes the new current.
3113+
manager.removeAccount(active!);
3114+
3115+
const after = manager.getCurrentAccountForFamily("codex");
3116+
expect(after?.refreshToken).toBe("token-3");
3117+
// Successor must be explicitly stamped with the rotation
3118+
// reason so callers can tell the pool retargeted them rather
3119+
// than assuming they still hold the account they originally
3120+
// selected.
3121+
expect(after?.lastSwitchReason).toBe("rotation");
3122+
3123+
// The untouched account (token-1) retains its prior reason,
3124+
// proving we only stamp the successor the retarget landed on.
3125+
const untouched = manager.getAccountByIndex(0);
3126+
expect(untouched?.refreshToken).toBe("token-1");
3127+
expect(untouched?.lastSwitchReason).toBe("initial");
3128+
});
3129+
3130+
it("does not stamp a successor when every remaining account is disabled", () => {
3131+
const now = Date.now();
3132+
const stored = {
3133+
version: 3 as const,
3134+
activeIndex: 2,
3135+
activeIndexByFamily: { codex: 2 },
3136+
accounts: [
3137+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, enabled: false, lastSwitchReason: "initial" as const },
3138+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, enabled: false, lastSwitchReason: "initial" as const },
3139+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, enabled: true, lastSwitchReason: "initial" as const },
3140+
],
3141+
};
3142+
3143+
const manager = new AccountManager(undefined, stored as never);
3144+
const active = manager.getCurrentAccountForFamily("codex");
3145+
expect(active?.refreshToken).toBe("token-3");
3146+
3147+
// Remove the last enabled account; remaining pool is entirely
3148+
// disabled. Pointer must fall to -1 (no routable account).
3149+
manager.removeAccount(active!);
3150+
3151+
expect(manager.getCurrentAccountForFamily("codex")).toBeNull();
3152+
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
3153+
3154+
// Neither surviving (disabled) account may be silently
3155+
// promoted by having rotation stamped on them. Their stored
3156+
// reason must stay "initial".
3157+
for (let i = 0; i < manager.getAccountCount(); i++) {
3158+
const acc = manager.getAccountByIndex(i);
3159+
expect(acc?.lastSwitchReason).toBe("initial");
3160+
}
3161+
});
3162+
3163+
it("stamps the sole enabled successor when all other peers are disabled", () => {
3164+
const now = Date.now();
3165+
const stored = {
3166+
version: 3 as const,
3167+
activeIndex: 0,
3168+
activeIndexByFamily: { codex: 0 },
3169+
accounts: [
3170+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, enabled: true, lastSwitchReason: "initial" as const },
3171+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, enabled: false, lastSwitchReason: "initial" as const },
3172+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, enabled: true, lastSwitchReason: "initial" as const },
3173+
],
3174+
};
3175+
3176+
const manager = new AccountManager(undefined, stored as never);
3177+
const active = manager.getCurrentAccountForFamily("codex");
3178+
expect(active?.refreshToken).toBe("token-1");
3179+
3180+
// Remove the currently active account (token-1). token-2 is
3181+
// disabled, so findNextEnabled must skip it and land on
3182+
// token-3 (the single remaining enabled peer).
3183+
manager.removeAccount(active!);
3184+
3185+
const after = manager.getCurrentAccountForFamily("codex");
3186+
expect(after?.refreshToken).toBe("token-3");
3187+
expect(after?.lastSwitchReason).toBe("rotation");
3188+
3189+
// The disabled peer that was skipped during retarget must
3190+
// keep its original reason — we only stamp the account the
3191+
// pointer actually lands on.
3192+
const disabledPeer = manager.getAccountByIndex(0);
3193+
expect(disabledPeer?.refreshToken).toBe("token-2");
3194+
expect(disabledPeer?.lastSwitchReason).toBe("initial");
3195+
});
3196+
});
30813197
describe("flushPendingSave", () => {
30823198
it("flushes pending debounced save", async () => {
30833199
const { saveAccounts } = await import("../lib/storage.js");

0 commit comments

Comments
 (0)