fix(sched): 额度闸门的接管探测退避 + 拦截日志限频 + 同邮箱多身份标注
问题
用户报: 账号选择很慢, 而且额度不满足的账号也会被轮询(请求 + 日志).
复核后的真实成因(与第一轮判断不同, 见 note 的 ## Correction):
- 排序不是瓶颈. 额度耗尽/买不起的账号是排到最后而非剔除(candidates.ts),
排序本身是本地 Map 读, 纳秒级. - 真正的代价是探测. 额度闸门命中时会为发现[别的部署建的已付费会话]发一次
真实只读探测(GET /session). 那次探测是每请求每账号一次: state 是每请求
一份的, 而 refresh() 在有在途请求时自跳过 ---- 于是[正在用]时几乎每次都白跑.
池内 N 个额度不足的账号 x M 个在途请求 = N x M 次串行上游往返, 全部发生在首字节之前. - 日志刷屏来自同一条路径: 每个被额度拦下的账号每个请求各记一条 skip 行.
- 同邮箱账号被连着轮询: key 用 Freebuff 用户 id(同邮箱 GitHub/Google 是两个独立
账号), 而 sticky 的平局规则含_rr索引, 索引相邻的同邮箱账号会被连续选中. - 附带发现: readAccountUser 的兜底扫描命中多个同邮箱文件时返回 null,
调用方于是报 401 ---- 账号在列表里却用不了, 表现就是[账号被隐藏了].
改动
- 探测退避(src/context/sched/account-gates.ts + account-runtime.ts):
探测成功或失败都按 60s 窗口延后下一次, 窗口挂 runtime(跨请求生效);
窗口内makePaidUpstreamChecker返回空串, 调用方视为[没有可接管会话] -> 零往返.
探测失败从抛错改为降级(按闸门结论继续 + 告警). - 拦截日志限频(src/util/log.ts):
skipLogOnce按(账号, 拦截码)60s 只记一条. - 凭据解析确定化(src/auth-store/accounts.ts): 命中多个同邮箱文件时按
key 精确 > 小写 key > 邮箱, 同档按文件名序 ---- 不再返回 null. - 控制台标注(dashboard): 同邮箱多身份挂[同邮箱]徽章, 消除[账号重复/被覆盖]的观感.
测试(均已实测可证伪)
| 用例 | 断言 | 破坏实现后的表现 |
|---|---|---|
probe-backoff.ts | 首轮每账号 1 次探测; 窗口内 5 个请求 0 新增探测; 4 个请求只留 2 条 skip 日志 | 去掉窗口早退 -> got 12; 换回 logger.info -> got 8 |
duplicate-email-resolve.ts | 同邮箱多身份解析确定且精确命中 | 改回 return null -> 红在[兜底扫描必须给出确定结果] |
same-email-badge.ts | 同邮箱行挂徽章, 唯一邮箱不挂 | 计数换成空 Map -> got [0,0,0] |
本地已验证: pre-commit 门禁全绿; smoke 全量通过; 三个 tsconfig 类型检查无新增错误;
受影响的 5 个 verify 套件通过.
决策记录
.agents/notes/implemented/feature/2026-10-09-quota-gate-probe-throttle.md
(含 Alternatives considered: 为什么不直接剔除候选)
Summary by cubic
Fixes slow account selection by backing off paid-session takeover probes, throttling quota skip logs, and resolving same-email credential ambiguity, with a dashboard badge for shared emails.
When an account's quota gate blocked a new purchase, the pool issued a real read-only probe (GET /session) per account per request — N quota-ineligible accounts × M in-flight requests meant N×M serial upstream round-trips before the first byte. Backoff now lives on the runtime (cross-request, resets on runtime rebuild) and only blocks re-asking upstream; known paid sessions from local snapshots still work inside the window. Concurrent probes on the same runtime share a single in-flight refresh, so parallel requests don't duplicate upstream calls. Skipped refreshes (while a request is in flight) don't open the window, so a real probe can fire immediately after. Probe failures degrade to the gate's conclusion with a warning instead of throwing.
Skip logs for blocked accounts are capped at one line per 60s window, and the throttle window only opens when the line is actually emitted — a level filter that suppresses the log leaves the window closed. When the rate-limit table hits capacity it only reclaims expired entries and never evicts an unexpired one.
Credential resolution previously returned null when the fallback scan matched multiple same-email files (GitHub/Google logins use separate account keys), surfacing as 401 "Account not found". It now picks deterministically (exact key > lowercase key > email, account key tiebreak), and the dashboard marks rows sharing an email with a "Same email" badge. The ambiguity warning is not covered by the quota-skip throttle and repeats per scan.
All changes are covered by tests whose assertions fail if the change is reverted.