Skip to content

Commit 07c2032

Browse files
committed
fix(accounts): signal removed-current instead of silent retarget (HI-04)
PR #413 hardened removeAccount pointer normalization so the current pointer no longer defaults to -1 when the active account is removed while other accounts remain in the pool. It did so by retargeting the pointer onto the successor account at the post-splice slot, which silently substitutes a different account for the caller's "current" without any audit trail. HI-04 (from .sisyphus/notepads/deep-audit/reports/accounts-rotation.json) flagged this as a correctness issue: getCurrentAccountForFamily() returns an account the caller never selected, with no lastSwitchReason indicating that the pool chose it. This masks pool-driven retargets in logs, dashboards, and session-affinity consumers that key on lastSwitchReason to distinguish user-selected from pool-selected identities. Fix: when removeAccount retargets the active pointer off the removed slot onto a new successor (priorActive[family] === idx), stamp lastSwitchReason="rotation" on that successor. This mirrors the existing convention already used by setActiveIndex() and markSwitched() for pool-driven selection events, so downstream observers see a consistent retarget signal instead of the successor's stale prior reason. Successors are deduped across families (lastSwitchReason is per-account, not per-family) via a Set, and the signal is only applied when we actually retargeted off the removed slot. If the pool collapses to no routable account (every remaining peer disabled), no successor is stamped and the pointer falls to -1 — matching the "no routable account" contract PR #413 established. Tests (test/accounts.test.ts "removed-current retarget signal (HI-04)"): - removing the currently active (middle) account stamps rotation on the successor and leaves untouched peers with their prior reason - removing the last enabled account when every remaining peer is disabled yields pointer=-1 and does NOT stamp any disabled peer - removing the currently active account with exactly one other enabled peer (plus a disabled peer that findNextEnabled must skip) lands on the enabled successor and stamps rotation only on it Source: .sisyphus/notepads/deep-audit/reports/accounts-rotation.json HI-04
1 parent 3f1c1fe commit 07c2032

2 files changed

Lines changed: 153 additions & 4 deletions

File tree

lib/accounts.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1316,8 +1316,14 @@ export class AccountManager {
13161316

13171317
// Snapshot family pointers before splice so we can distinguish "was
13181318
// pointing at the removed account" from "was pointing past it".
1319-
const priorCursor: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
1320-
const priorActive: Record<ModelFamily, number> = {} as Record<ModelFamily, number>;
1319+
const priorCursor: Record<ModelFamily, number> = {} as Record<
1320+
ModelFamily,
1321+
number
1322+
>;
1323+
const priorActive: Record<ModelFamily, number> = {} as Record<
1324+
ModelFamily,
1325+
number
1326+
>;
13211327
for (const family of MODEL_FAMILIES) {
13221328
priorCursor[family] = this.cursorByFamily[family];
13231329
priorActive[family] = this.currentAccountIndexByFamily[family];
@@ -1336,6 +1342,14 @@ export class AccountManager {
13361342
return true;
13371343
}
13381344

1345+
// Track successor accounts that we explicitly retarget onto because the
1346+
// removed account was the caller's "current" for a given family. We
1347+
// stamp each such successor with lastSwitchReason="rotation" so the
1348+
// retarget is auditable instead of silently carrying whatever stale
1349+
// reason the successor already had (HI-04). De-duplicated across
1350+
// families because lastSwitchReason is per-account, not per-family.
1351+
const retargetedSuccessors = new Set<number>();
1352+
13391353
for (const family of MODEL_FAMILIES) {
13401354
// Cursor: shift down if it was past the removed index, then normalize
13411355
// into [0, length). If the cursor was pointing AT the removed slot
@@ -1358,18 +1372,37 @@ export class AccountManager {
13581372
// AT the removed slot (or is now dangling off the end after
13591373
// splice), advance to the next enabled account instead of
13601374
// defaulting to -1. Fall back to -1 only when every remaining
1361-
// account is disabled or the pool is empty.
1375+
// account is disabled or the pool is empty. When we retarget off
1376+
// the removed slot onto a different account, record the successor
1377+
// so we can explicitly signal the retarget via lastSwitchReason.
13621378
let active = priorActive[family];
1379+
const activeWasRemoved = active === idx;
13631380
if (active > idx) {
13641381
active -= 1;
1365-
} else if (active === idx) {
1382+
} else if (activeWasRemoved) {
13661383
// Same numeric position now hosts the successor account.
13671384
active = this.findNextEnabled(Math.min(idx, this.accounts.length - 1));
13681385
}
13691386
if (active >= this.accounts.length) {
13701387
active = this.findNextEnabled(0);
13711388
}
13721389
this.currentAccountIndexByFamily[family] = active;
1390+
if (activeWasRemoved && active >= 0 && active < this.accounts.length) {
1391+
retargetedSuccessors.add(active);
1392+
}
1393+
}
1394+
1395+
// Stamp retarget signal on each successor account that replaced a
1396+
// removed "current" pointer. This mirrors the existing rotation
1397+
// convention used by setActiveIndex / markSwitched, so downstream
1398+
// callers observing lastSwitchReason see a clear audit trail that the
1399+
// pool re-chose this account rather than the user selecting it
1400+
// themselves.
1401+
for (const successorIdx of retargetedSuccessors) {
1402+
const successor = this.accounts[successorIdx];
1403+
if (successor) {
1404+
successor.lastSwitchReason = "rotation";
1405+
}
13731406
}
13741407

13751408
return true;

test/accounts.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2890,6 +2890,122 @@ describe("AccountManager", () => {
28902890
});
28912891
});
28922892

2893+
describe("removed-current retarget signal (HI-04)", () => {
2894+
// PR #413 hardened removeAccount() to avoid the pointer-dangle
2895+
// default-to-minus-one behavior, but it silently retargeted the
2896+
// current pointer onto a different account when the removed
2897+
// account WAS the current one. HI-04 flagged that silent
2898+
// retarget: callers observing getCurrentAccountForFamily()
2899+
// receive an account they never selected with no audit trail.
2900+
//
2901+
// The fix stamps lastSwitchReason="rotation" on the successor
2902+
// whenever removeAccount() retargets off the removed slot. This
2903+
// mirrors the convention already used by setActiveIndex() and
2904+
// markSwitched() for pool-driven selection events.
2905+
2906+
it("stamps lastSwitchReason='rotation' on successor when current is removed", () => {
2907+
const now = Date.now();
2908+
const stored = {
2909+
version: 3 as const,
2910+
activeIndex: 1,
2911+
activeIndexByFamily: { codex: 1 },
2912+
accounts: [
2913+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, lastSwitchReason: "initial" as const },
2914+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, lastSwitchReason: "initial" as const },
2915+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, lastSwitchReason: "initial" as const },
2916+
],
2917+
};
2918+
2919+
const manager = new AccountManager(undefined, stored as never);
2920+
const active = manager.getCurrentAccountForFamily("codex");
2921+
expect(active?.refreshToken).toBe("token-2");
2922+
2923+
// Remove the currently active (middle) account. The successor
2924+
// (token-3) shifts into index 1 and becomes the new current.
2925+
manager.removeAccount(active!);
2926+
2927+
const after = manager.getCurrentAccountForFamily("codex");
2928+
expect(after?.refreshToken).toBe("token-3");
2929+
// Successor must be explicitly stamped with the rotation
2930+
// reason so callers can tell the pool retargeted them rather
2931+
// than assuming they still hold the account they originally
2932+
// selected.
2933+
expect(after?.lastSwitchReason).toBe("rotation");
2934+
2935+
// The untouched account (token-1) retains its prior reason,
2936+
// proving we only stamp the successor the retarget landed on.
2937+
const untouched = manager.getAccountByIndex(0);
2938+
expect(untouched?.refreshToken).toBe("token-1");
2939+
expect(untouched?.lastSwitchReason).toBe("initial");
2940+
});
2941+
2942+
it("does not stamp a successor when every remaining account is disabled", () => {
2943+
const now = Date.now();
2944+
const stored = {
2945+
version: 3 as const,
2946+
activeIndex: 2,
2947+
activeIndexByFamily: { codex: 2 },
2948+
accounts: [
2949+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, enabled: false, lastSwitchReason: "initial" as const },
2950+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, enabled: false, lastSwitchReason: "initial" as const },
2951+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, enabled: true, lastSwitchReason: "initial" as const },
2952+
],
2953+
};
2954+
2955+
const manager = new AccountManager(undefined, stored as never);
2956+
const active = manager.getCurrentAccountForFamily("codex");
2957+
expect(active?.refreshToken).toBe("token-3");
2958+
2959+
// Remove the last enabled account; remaining pool is entirely
2960+
// disabled. Pointer must fall to -1 (no routable account).
2961+
manager.removeAccount(active!);
2962+
2963+
expect(manager.getCurrentAccountForFamily("codex")).toBeNull();
2964+
expect(manager.getActiveIndexForFamily("codex")).toBe(-1);
2965+
2966+
// Neither surviving (disabled) account may be silently
2967+
// promoted by having rotation stamped on them. Their stored
2968+
// reason must stay "initial".
2969+
for (let i = 0; i < manager.getAccountCount(); i++) {
2970+
const acc = manager.getAccountByIndex(i);
2971+
expect(acc?.lastSwitchReason).toBe("initial");
2972+
}
2973+
});
2974+
2975+
it("stamps the sole enabled successor when all other peers are disabled", () => {
2976+
const now = Date.now();
2977+
const stored = {
2978+
version: 3 as const,
2979+
activeIndex: 0,
2980+
activeIndexByFamily: { codex: 0 },
2981+
accounts: [
2982+
{ refreshToken: "token-1", addedAt: now, lastUsed: now, enabled: true, lastSwitchReason: "initial" as const },
2983+
{ refreshToken: "token-2", addedAt: now, lastUsed: now, enabled: false, lastSwitchReason: "initial" as const },
2984+
{ refreshToken: "token-3", addedAt: now, lastUsed: now, enabled: true, lastSwitchReason: "initial" as const },
2985+
],
2986+
};
2987+
2988+
const manager = new AccountManager(undefined, stored as never);
2989+
const active = manager.getCurrentAccountForFamily("codex");
2990+
expect(active?.refreshToken).toBe("token-1");
2991+
2992+
// Remove the currently active account (token-1). token-2 is
2993+
// disabled, so findNextEnabled must skip it and land on
2994+
// token-3 (the single remaining enabled peer).
2995+
manager.removeAccount(active!);
2996+
2997+
const after = manager.getCurrentAccountForFamily("codex");
2998+
expect(after?.refreshToken).toBe("token-3");
2999+
expect(after?.lastSwitchReason).toBe("rotation");
3000+
3001+
// The disabled peer that was skipped during retarget must
3002+
// keep its original reason — we only stamp the account the
3003+
// pointer actually lands on.
3004+
const disabledPeer = manager.getAccountByIndex(0);
3005+
expect(disabledPeer?.refreshToken).toBe("token-2");
3006+
expect(disabledPeer?.lastSwitchReason).toBe("initial");
3007+
});
3008+
});
28933009
describe("flushPendingSave", () => {
28943010
it("flushes pending debounced save", async () => {
28953011
const { saveAccounts } = await import("../lib/storage.js");

0 commit comments

Comments
 (0)