dsh-workbuddy-xdpool 1.8.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/lib/bin.js +130 -36
- package/lib/client.js +25 -3
- package/lib/index.d.ts +63 -2
- package/lib/index.js +146 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,44 @@
|
|
|
4
4
|
|
|
5
5
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
6
6
|
|
|
7
|
+
## 1.8.1 — 一个过期的登录状态,不再拖着整个池子陪跑
|
|
8
|
+
|
|
9
|
+
> 有人的卡片上,好几个账号下面顶着一大段红色英文,大意是「上游不认这个登录了,去桌面端重新登录」。话说得没错,可问题是:它只说了结论,没解决过程——那几个账号明明已经废了,每次请求还是要挨个撞一遍,撞得头破血流,旁边几个好端端的账号一次都轮不上。
|
|
10
|
+
|
|
11
|
+
### 事情是怎么变成这样的
|
|
12
|
+
|
|
13
|
+
上游吊销一个登录状态时,**不会顺手把凭据文件里的过期时间改掉**。于是本机留着一份「看起来还有效」的凭据:过期时间写着 11 月 4 日,今天才 10 月,一切正常。
|
|
14
|
+
|
|
15
|
+
插件就信了。它把这三个账号当成健康账号,按顺序一个一个试:
|
|
16
|
+
|
|
17
|
+
1. 拿账号 A 去请求,上游回 401;
|
|
18
|
+
2. 插件想「可能是 token 过期了」,于是刷新 token;
|
|
19
|
+
3. 上游回答「这个 refresh token 早就没了」(`invalid_grant: Offline user session not found`);
|
|
20
|
+
4. 插件刷新失败,但**没有记仇**,转头又拿 A 去试;
|
|
21
|
+
5. 八次重试名额(`maxAttempts`)就这么全喂给了同一个死账号,健康账号一次都没被碰过。
|
|
22
|
+
|
|
23
|
+
所以现象是「明明还有能用的账号,请求却一直失败」,外加卡片上一片红。
|
|
24
|
+
|
|
25
|
+
### 现在怎么处理
|
|
26
|
+
|
|
27
|
+
给账号加了第三种状态:**判死**(前两种是「正常」和「限流冷却」)。
|
|
28
|
+
|
|
29
|
+
- **只有上游亲口说死才算死。** 判据是 `invalid_grant` / `Offline user session not found` 这类明确答复。网络超时、DNS 抽风、5xx 一律不算——否则一次网络抖动就会把好账号关进小黑屋半小时,那比原来的 bug 还难查。
|
|
30
|
+
- **判死的账号立刻退出轮换**,不再参与 `acquire` 的选择,请求当场换健康账号继续,不用等到八次重试耗尽。
|
|
31
|
+
- **想复活有两条路**:在 WorkBuddy 桌面端重新登录该账号(插件发现 refresh token 变了,自动放行),或者点卡片上的「清除所有冷却」手动再给它一次机会。
|
|
32
|
+
- **全池都死了的时候**,返回的不再是含含糊糊的「你还没登录」,而是 401 `session_dead`:`N/M WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`。「等待」和「重新登录」是两种完全不同的处置方式,不能混着报。
|
|
33
|
+
|
|
34
|
+
### 卡片上的红字也收拾了一下
|
|
35
|
+
|
|
36
|
+
- 原来那段整段英文报错**直接铺在「积分包」面板里**,把面板撑得老长,右边的「合计」都被挤出屏幕。现在折叠成一个红色小标「**登录已被拒绝**」,鼠标悬停才显示原文。
|
|
37
|
+
- 账号行上加了红色的「登录已被拒绝」标签,下面补一句「请在 WorkBuddy 桌面端重新登录该账号以恢复」——红色是故意的,用琥珀色会让人以为「等一会儿就好」,而这个状态等多久都不会好。
|
|
38
|
+
- 判死的账号**不再参与卡片刷新时的积分 / 签到探测**。以前每刷新一次卡片,就对每个死账号白打两个必定 401 的请求,刷新越勤越热闹。
|
|
39
|
+
|
|
40
|
+
### 已知边界
|
|
41
|
+
|
|
42
|
+
- 判死状态记在内存里,重启后重新判定。也就是说重启后的第一次请求还会再试一次那个账号,然后才把它请出去——这一次的代价是必要的,否则「重启一下就好了」的临时故障会被误判成永久失效。
|
|
43
|
+
- 国际版每日签到活动(`checkin-activity-status`)实测当前是 `active: false`,所以「多账号自动领 30 积分」目前没有可领的东西;国内版的活动是正常的,逐账号领取的链路本来就有。
|
|
44
|
+
|
|
7
45
|
## 1.8.0 — 四种模式,终于排得像个样子了
|
|
8
46
|
|
|
9
47
|
> 上一版加了第四种模式「每对话固定」,功能是好的,排版是坏的。坏得很实在:格子还是按三个排的,第四个只好委屈地掉到下一行,占着半个身位,像个插班生。
|
package/lib/bin.js
CHANGED
|
@@ -2644,6 +2644,28 @@ function idleWeight(lastUsedAt, now) {
|
|
|
2644
2644
|
}
|
|
2645
2645
|
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
2646
2646
|
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
2647
|
+
/**
|
|
2648
|
+
* Default rest for an account the upstream rejected the sign-in of (401/403).
|
|
2649
|
+
*
|
|
2650
|
+
* Unlike a rate limit there is no reset time to read: only signing in again in
|
|
2651
|
+
* the desktop app revives the account. The rest is therefore long enough that a
|
|
2652
|
+
* single request never burns its whole retry budget on the same dead credential,
|
|
2653
|
+
* but short enough that a user who just signed in is not locked out of their own
|
|
2654
|
+
* account for long — and a newer credential file clears the mark outright.
|
|
2655
|
+
*/
|
|
2656
|
+
const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
|
|
2657
|
+
/**
|
|
2658
|
+
* Does this refresh failure prove the credential itself is dead?
|
|
2659
|
+
*
|
|
2660
|
+
* The upstream answers a revoked refresh token with `invalid_grant: Offline user
|
|
2661
|
+
* session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
|
|
2662
|
+
* prove anything about the credential and must not mark it dead — that would
|
|
2663
|
+
* take a working account out of rotation for half an hour over a hiccup.
|
|
2664
|
+
*/
|
|
2665
|
+
function isCredentialDeadError(error) {
|
|
2666
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2667
|
+
return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
|
|
2668
|
+
}
|
|
2647
2669
|
var WorkBuddyAccountPool = class {
|
|
2648
2670
|
logger;
|
|
2649
2671
|
authDirs;
|
|
@@ -2654,6 +2676,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2654
2676
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
2655
2677
|
*/
|
|
2656
2678
|
exhaustCooldownMs;
|
|
2679
|
+
/**
|
|
2680
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
2681
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
2682
|
+
*/
|
|
2683
|
+
credentialDeadCooldownMs;
|
|
2657
2684
|
client;
|
|
2658
2685
|
refreshMarginMs;
|
|
2659
2686
|
accounts = [];
|
|
@@ -2736,6 +2763,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2736
2763
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
2737
2764
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
2738
2765
|
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
2766
|
+
this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
|
|
2739
2767
|
this.client = options.client;
|
|
2740
2768
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2741
2769
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -2822,11 +2850,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2822
2850
|
});
|
|
2823
2851
|
continue;
|
|
2824
2852
|
}
|
|
2825
|
-
if (isFresher(credential, existing.credential))
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2853
|
+
if (isFresher(credential, existing.credential)) {
|
|
2854
|
+
const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
|
|
2855
|
+
byId.set(id, {
|
|
2856
|
+
...existing,
|
|
2857
|
+
credential,
|
|
2858
|
+
label: accountLabel(credential),
|
|
2859
|
+
...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2830
2862
|
}
|
|
2831
2863
|
const ordered = [...byId.values()];
|
|
2832
2864
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
@@ -2857,6 +2889,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2857
2889
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
2858
2890
|
}
|
|
2859
2891
|
if (account.cooldownUntilMs > now) return false;
|
|
2892
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) return false;
|
|
2860
2893
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
2861
2894
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
2862
2895
|
return true;
|
|
@@ -2882,15 +2915,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2882
2915
|
total: 0,
|
|
2883
2916
|
cooling: 0,
|
|
2884
2917
|
disabled: 0,
|
|
2918
|
+
dead: 0,
|
|
2885
2919
|
reason: "empty"
|
|
2886
2920
|
};
|
|
2887
2921
|
let cooling = 0;
|
|
2888
2922
|
let disabled = 0;
|
|
2923
|
+
let dead = 0;
|
|
2889
2924
|
for (const account of inRegion) {
|
|
2890
2925
|
if (this.disabledIds.has(account.id)) {
|
|
2891
2926
|
disabled += 1;
|
|
2892
2927
|
continue;
|
|
2893
2928
|
}
|
|
2929
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) {
|
|
2930
|
+
dead += 1;
|
|
2931
|
+
continue;
|
|
2932
|
+
}
|
|
2894
2933
|
const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
|
|
2895
2934
|
if (account.cooldownUntilMs > now || modelCooling) {
|
|
2896
2935
|
cooling += 1;
|
|
@@ -2903,26 +2942,37 @@ var WorkBuddyAccountPool = class {
|
|
|
2903
2942
|
total: inRegion.length,
|
|
2904
2943
|
cooling,
|
|
2905
2944
|
disabled,
|
|
2945
|
+
dead,
|
|
2906
2946
|
reason: "reserve"
|
|
2907
2947
|
};
|
|
2908
2948
|
}
|
|
2909
2949
|
}
|
|
2950
|
+
if (dead > 0) return {
|
|
2951
|
+
total: inRegion.length,
|
|
2952
|
+
cooling,
|
|
2953
|
+
disabled,
|
|
2954
|
+
dead,
|
|
2955
|
+
reason: "session_dead"
|
|
2956
|
+
};
|
|
2910
2957
|
if (cooling > 0) return {
|
|
2911
2958
|
total: inRegion.length,
|
|
2912
2959
|
cooling,
|
|
2913
2960
|
disabled,
|
|
2961
|
+
dead,
|
|
2914
2962
|
reason: "cooling"
|
|
2915
2963
|
};
|
|
2916
2964
|
if (disabled > 0) return {
|
|
2917
2965
|
total: inRegion.length,
|
|
2918
2966
|
cooling,
|
|
2919
2967
|
disabled,
|
|
2968
|
+
dead,
|
|
2920
2969
|
reason: "disabled"
|
|
2921
2970
|
};
|
|
2922
2971
|
return {
|
|
2923
2972
|
total: inRegion.length,
|
|
2924
2973
|
cooling,
|
|
2925
2974
|
disabled,
|
|
2975
|
+
dead,
|
|
2926
2976
|
reason: "none"
|
|
2927
2977
|
};
|
|
2928
2978
|
}
|
|
@@ -3026,31 +3076,28 @@ var WorkBuddyAccountPool = class {
|
|
|
3026
3076
|
*/
|
|
3027
3077
|
async acquire(modelId, region, conversationKey) {
|
|
3028
3078
|
if (this.accounts.length === 0) await this.scan();
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
if (this.preferredId !== void 0) {
|
|
3036
|
-
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3037
|
-
if (preferred !== void 0) {
|
|
3038
|
-
await this.ensureFresh(preferred);
|
|
3039
|
-
return preferred;
|
|
3079
|
+
const maxTries = this.accounts.length + 1;
|
|
3080
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
3081
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
3082
|
+
if (pool.length === 0 && attempt === 0) {
|
|
3083
|
+
await this.scan();
|
|
3084
|
+
pool = this.available(Date.now(), modelId, region);
|
|
3040
3085
|
}
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3086
|
+
if (pool.length === 0) return void 0;
|
|
3087
|
+
if (this.preferredId !== void 0) {
|
|
3088
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3089
|
+
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
3090
|
+
}
|
|
3091
|
+
if (this.distribution === "sticky") {
|
|
3092
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3093
|
+
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3047
3094
|
}
|
|
3095
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3096
|
+
if (account === void 0) return void 0;
|
|
3097
|
+
if (!await this.ensureFresh(account)) continue;
|
|
3098
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3099
|
+
return account;
|
|
3048
3100
|
}
|
|
3049
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3050
|
-
if (account === void 0) return void 0;
|
|
3051
|
-
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3052
|
-
await this.ensureFresh(account);
|
|
3053
|
-
return account;
|
|
3054
3101
|
}
|
|
3055
3102
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
3056
3103
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
@@ -3154,31 +3201,68 @@ var WorkBuddyAccountPool = class {
|
|
|
3154
3201
|
return newest?.id;
|
|
3155
3202
|
}
|
|
3156
3203
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
3157
|
-
async refreshAccount(accountId) {
|
|
3204
|
+
async refreshAccount(accountId, options) {
|
|
3205
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3206
|
+
if (account === void 0) return false;
|
|
3207
|
+
return await this.ensureFresh(account, options?.force === true);
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
3211
|
+
*
|
|
3212
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
3213
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
3214
|
+
* picks the same account again — its credential file still claims to be valid
|
|
3215
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
3216
|
+
* healthy accounts are never tried.
|
|
3217
|
+
*/
|
|
3218
|
+
penalizeCredentialDead(accountId) {
|
|
3158
3219
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
3159
3220
|
if (account === void 0) return;
|
|
3160
|
-
|
|
3221
|
+
const until = Date.now() + this.credentialDeadCooldownMs;
|
|
3222
|
+
account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
|
|
3223
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
|
|
3224
|
+
}
|
|
3225
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
3226
|
+
clearCredentialDead(accountId) {
|
|
3227
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3228
|
+
if (account === void 0) return;
|
|
3229
|
+
account.credentialDeadUntilMs = 0;
|
|
3230
|
+
}
|
|
3231
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
3232
|
+
deadCredentials() {
|
|
3233
|
+
const now = Date.now();
|
|
3234
|
+
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
3161
3235
|
}
|
|
3162
3236
|
/**
|
|
3163
3237
|
* Refresh the account's access token when it is within the margin (or already
|
|
3164
3238
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
3165
3239
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
3166
3240
|
* endpoint never takes down a working session.
|
|
3241
|
+
*
|
|
3242
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
3243
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
3244
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
3245
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
3246
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
3247
|
+
*
|
|
3248
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
3249
|
+
* valid), false when the credential was proven dead.
|
|
3167
3250
|
*/
|
|
3168
|
-
async ensureFresh(account) {
|
|
3169
|
-
if (this.client === void 0) return;
|
|
3251
|
+
async ensureFresh(account, force = false) {
|
|
3252
|
+
if (this.client === void 0) return true;
|
|
3170
3253
|
const credential = account.credential;
|
|
3171
|
-
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
3254
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
|
|
3172
3255
|
const existing = this.refreshInflight.get(account.id);
|
|
3173
3256
|
if (existing !== void 0) {
|
|
3174
3257
|
await existing;
|
|
3175
|
-
return;
|
|
3258
|
+
return (account.credentialDeadUntilMs ?? 0) <= Date.now();
|
|
3176
3259
|
}
|
|
3177
3260
|
const run = (async () => {
|
|
3178
3261
|
if (credential.refreshToken === "") {
|
|
3179
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
3262
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return true;
|
|
3180
3263
|
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
3181
|
-
|
|
3264
|
+
this.penalizeCredentialDead(account.id);
|
|
3265
|
+
return false;
|
|
3182
3266
|
}
|
|
3183
3267
|
try {
|
|
3184
3268
|
const outcome = await this.client.refreshToken(credential);
|
|
@@ -3189,15 +3273,23 @@ var WorkBuddyAccountPool = class {
|
|
|
3189
3273
|
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
3190
3274
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
3191
3275
|
};
|
|
3276
|
+
this.clearCredentialDead(account.id);
|
|
3192
3277
|
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
3278
|
+
return true;
|
|
3193
3279
|
} catch (error) {
|
|
3280
|
+
if (isCredentialDeadError(error)) {
|
|
3281
|
+
this.penalizeCredentialDead(account.id);
|
|
3282
|
+
this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
|
|
3283
|
+
return false;
|
|
3284
|
+
}
|
|
3194
3285
|
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
3195
3286
|
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
3287
|
+
return true;
|
|
3196
3288
|
}
|
|
3197
3289
|
})();
|
|
3198
3290
|
this.refreshInflight.set(account.id, run);
|
|
3199
3291
|
try {
|
|
3200
|
-
await run;
|
|
3292
|
+
return await run;
|
|
3201
3293
|
} finally {
|
|
3202
3294
|
this.refreshInflight.delete(account.id);
|
|
3203
3295
|
}
|
|
@@ -3246,6 +3338,7 @@ ${new Date(until).toISOString()}
|
|
|
3246
3338
|
account.cooldownUntilMs = 0;
|
|
3247
3339
|
account.modelCooldowns = {};
|
|
3248
3340
|
account.rateLimitHits = 0;
|
|
3341
|
+
account.credentialDeadUntilMs = 0;
|
|
3249
3342
|
}
|
|
3250
3343
|
}
|
|
3251
3344
|
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
@@ -3255,6 +3348,7 @@ ${new Date(until).toISOString()}
|
|
|
3255
3348
|
return {
|
|
3256
3349
|
count: this.accounts.length,
|
|
3257
3350
|
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
3351
|
+
dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
|
|
3258
3352
|
lastScanAtMs: this.lastScanAtMs
|
|
3259
3353
|
};
|
|
3260
3354
|
}
|
package/lib/client.js
CHANGED
|
@@ -249,7 +249,11 @@ window.__ModuleLoader__.load({
|
|
|
249
249
|
.dsm-workbuddy-xdpool-account-tag{padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-success-subtle,rgba(34,160,107,.12));color:var(--dsw-alias-state-success-primary,#22a06b)}
|
|
250
250
|
.dsm-workbuddy-xdpool-account-tag-cooling{background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.15));color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
251
251
|
.dsm-workbuddy-xdpool-account-tag-error{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
252
|
+
/* A rejected sign-in is red, not amber: unlike a cooldown it will not clear by
|
|
253
|
+
waiting, and it is the one state where the user has to go do something. */
|
|
254
|
+
.dsm-workbuddy-xdpool-account-tag-dead{background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
252
255
|
.dsm-workbuddy-xdpool-account-meta{color:var(--dsw-alias-label-tertiary,#9aa0a8);font-size:12px;line-height:18px;display:flex;flex-wrap:wrap;gap:10px}
|
|
256
|
+
.dsm-workbuddy-xdpool-account-meta-dead{color:var(--dsw-alias-state-error-primary,#ef4444)}
|
|
253
257
|
.dsm-workbuddy-xdpool-account-modelcool{display:flex;flex-wrap:wrap;gap:6px;margin-top:2px}
|
|
254
258
|
.dsm-workbuddy-xdpool-account-modelcool-chip{display:inline-flex;align-items:center;gap:4px;padding:1px 8px;border-radius:999px;font-size:11px;line-height:18px;background:var(--dsw-alias-state-warning-subtle,rgba(217,119,6,.12));color:var(--dsw-alias-state-warning-primary,#d97706)}
|
|
255
259
|
.dsm-workbuddy-xdpool-account-error{margin:0;color:var(--dsw-alias-state-error-primary,#ef4444);font-size:13px;line-height:20px}
|
|
@@ -260,6 +264,9 @@ window.__ModuleLoader__.load({
|
|
|
260
264
|
.dsm-workbuddy-xdpool-panel-title{color:var(--dsw-alias-label-tertiary,#999);font-size:11px;line-height:16px;letter-spacing:.03em;text-transform:uppercase;font-weight:600}
|
|
261
265
|
.dsm-workbuddy-xdpool-panel-empty{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:20px}
|
|
262
266
|
.dsm-workbuddy-xdpool-panel-error{color:var(--dsw-alias-state-error-primary,#ef4444);font-size:12px;line-height:18px;word-break:break-word}
|
|
267
|
+
/* A rejected sign-in gets a compact chip: the upstream paragraph is long, and
|
|
268
|
+
printed inline it pushed the neighbouring "Total" panel off screen. */
|
|
269
|
+
.dsm-workbuddy-xdpool-panel-error-dead{display:inline-flex;align-items:center;align-self:flex-start;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-state-error-subtle,rgba(239,68,68,.12));font-size:11px;line-height:18px;white-space:nowrap;cursor:help}
|
|
263
270
|
.dsm-workbuddy-xdpool-panel-foot{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-top:9px;padding-top:9px;border-top:1px solid var(--dsw-alias-border-l2,#36373b);color:var(--dsw-alias-label-secondary,#c6c9d0);font-size:12px;line-height:18px}
|
|
264
271
|
.dsm-workbuddy-xdpool-panel-foot strong{color:var(--dsw-alias-label-primary,#e6e6e6);font-size:15px;font-variant-numeric:tabular-nums}
|
|
265
272
|
.dsm-workbuddy-xdpool-packages{display:flex;flex-direction:column;gap:5px;margin:0;padding:0;list-style:none}
|
|
@@ -1735,9 +1742,13 @@ window.__ModuleLoader__.load({
|
|
|
1735
1742
|
function AccountBlock({ account, t, checkinBusyId, onClaimCheckin, accountBusyId, onToggleDisabled, onIgnoreAccount, onSaveCreditReserve, reserveBusyId }) {
|
|
1736
1743
|
const isDisabled = account.disabled === true;
|
|
1737
1744
|
const isCooling = account.cooling === true;
|
|
1745
|
+
const isDead = account.credentialDead === true;
|
|
1738
1746
|
const cooldownUntil = account.cooldownUntil !== void 0 ? Date.parse(account.cooldownUntil) : void 0;
|
|
1739
1747
|
const modelCooldowns = account.modelCooldowns ?? [];
|
|
1740
|
-
const tag =
|
|
1748
|
+
const tag = isDead ? {
|
|
1749
|
+
text: t?.("row.credentialDeadTag") ?? "Sign-in rejected",
|
|
1750
|
+
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-dead"
|
|
1751
|
+
} : isCooling ? {
|
|
1741
1752
|
text: t?.("row.cooling") ?? "Cooling",
|
|
1742
1753
|
cls: "dsm-workbuddy-xdpool-account-tag dsm-workbuddy-xdpool-account-tag-cooling"
|
|
1743
1754
|
} : null;
|
|
@@ -1795,6 +1806,10 @@ window.__ModuleLoader__.load({
|
|
|
1795
1806
|
t?.("row.cooldownHits", { hits: account.rateLimitHits ?? 0 }) ?? `${account.rateLimitHits ?? 0} hit(s)`
|
|
1796
1807
|
]
|
|
1797
1808
|
}) : null,
|
|
1809
|
+
isDead ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1810
|
+
className: "dsm-workbuddy-xdpool-account-meta dsm-workbuddy-xdpool-account-meta-dead",
|
|
1811
|
+
children: t?.("row.credentialDeadHint") ?? "Sign in again in the WorkBuddy desktop app to restore it"
|
|
1812
|
+
}) : null,
|
|
1798
1813
|
modelCooldowns.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1799
1814
|
className: "dsm-workbuddy-xdpool-account-modelcool",
|
|
1800
1815
|
children: modelCooldowns.map((mc) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
@@ -1976,8 +1991,9 @@ window.__ModuleLoader__.load({
|
|
|
1976
1991
|
children: t?.("row.creditsPackages") ?? "Credit packages"
|
|
1977
1992
|
}),
|
|
1978
1993
|
account.creditsError !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1979
|
-
className: "dsm-workbuddy-xdpool-panel-error"
|
|
1980
|
-
|
|
1994
|
+
className: `dsm-workbuddy-xdpool-panel-error${account.credentialDead === true ? " dsm-workbuddy-xdpool-panel-error-dead" : ""}`,
|
|
1995
|
+
title: account.creditsError,
|
|
1996
|
+
children: account.credentialDead === true ? t?.("row.credentialDead") ?? "Sign-in rejected" : t?.("row.creditsError") ?? "credits unavailable"
|
|
1981
1997
|
}) : packages.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1982
1998
|
className: "dsm-workbuddy-xdpool-panel-empty",
|
|
1983
1999
|
children: "–"
|
|
@@ -2282,6 +2298,9 @@ window.__ModuleLoader__.load({
|
|
|
2282
2298
|
"row.creditsPackages": "Credit packages",
|
|
2283
2299
|
"row.creditsPackage": "{remain} / {size}",
|
|
2284
2300
|
"row.creditsError": "credits unavailable",
|
|
2301
|
+
"row.credentialDead": "Sign-in rejected",
|
|
2302
|
+
"row.credentialDeadTag": "Sign-in rejected",
|
|
2303
|
+
"row.credentialDeadHint": "The upstream no longer accepts this sign-in. Sign in again in the WorkBuddy desktop app to restore it.",
|
|
2285
2304
|
"row.creditsSoon": "Expiring in 3 days",
|
|
2286
2305
|
"row.creditsExpiresAt": "Expires {time}",
|
|
2287
2306
|
"row.creditsExpiresSoonTitle": "Expiring within 3 days",
|
|
@@ -2437,6 +2456,9 @@ window.__ModuleLoader__.load({
|
|
|
2437
2456
|
"row.creditsPackages": "积分包",
|
|
2438
2457
|
"row.creditsPackage": "{remain} / {size}",
|
|
2439
2458
|
"row.creditsError": "积分不可用",
|
|
2459
|
+
"row.credentialDead": "登录已被拒绝",
|
|
2460
|
+
"row.credentialDeadTag": "登录已被拒绝",
|
|
2461
|
+
"row.credentialDeadHint": "上游已不再接受这个登录状态。请在 WorkBuddy 桌面端重新登录该账号以恢复。",
|
|
2440
2462
|
"row.creditsSoon": "3 天内到期",
|
|
2441
2463
|
"row.creditsExpiresAt": "到期 {time}",
|
|
2442
2464
|
"row.creditsExpiresSoonTitle": "3 天内到期",
|
package/lib/index.d.ts
CHANGED
|
@@ -810,6 +810,20 @@ interface WorkBuddyAccount {
|
|
|
810
810
|
modelCooldowns: Record<string, number>;
|
|
811
811
|
/** Consecutive rate-limit hits, for diagnostics. */
|
|
812
812
|
rateLimitHits: number;
|
|
813
|
+
/**
|
|
814
|
+
* Epoch ms until which this account is skipped because the upstream REJECTED
|
|
815
|
+
* its sign-in (401/403), not because of a rate limit.
|
|
816
|
+
*
|
|
817
|
+
* A revoked session is invisible from the credential file: the upstream does
|
|
818
|
+
* not rewrite `expiresAtMs` when it kills a refresh token, so a dead account
|
|
819
|
+
* looks perfectly fresh and, without this field, gets picked again on the very
|
|
820
|
+
* next attempt. With `maxAttempts` retries that meant one dead account could
|
|
821
|
+
* absorb an entire request while healthy accounts were never tried.
|
|
822
|
+
*
|
|
823
|
+
* Only a fresh sign-in in the desktop app clears it, so the cooldown is long;
|
|
824
|
+
* `0`/undefined means "not known to be dead".
|
|
825
|
+
*/
|
|
826
|
+
credentialDeadUntilMs?: number;
|
|
813
827
|
}
|
|
814
828
|
/**
|
|
815
829
|
* Platform-default directories holding the desktop app's auth files.
|
|
@@ -845,6 +859,11 @@ interface AccountPoolOptions {
|
|
|
845
859
|
cooldownMs?: number;
|
|
846
860
|
/** How long an account rests after its credits run out (default 30 minutes). */
|
|
847
861
|
exhaustCooldownMs?: number;
|
|
862
|
+
/**
|
|
863
|
+
* How long an account rests after the upstream rejects its sign-in
|
|
864
|
+
* (default 30 minutes). Only a fresh sign-in in the desktop app clears it.
|
|
865
|
+
*/
|
|
866
|
+
credentialDeadCooldownMs?: number;
|
|
848
867
|
/** Upstream client used to refresh near-expiry tokens. */
|
|
849
868
|
client?: TokenRefresher;
|
|
850
869
|
/** Refresh this long before actual expiry; default five minutes. */
|
|
@@ -874,6 +893,11 @@ export declare class WorkBuddyAccountPool {
|
|
|
874
893
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
875
894
|
*/
|
|
876
895
|
private exhaustCooldownMs;
|
|
896
|
+
/**
|
|
897
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
898
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
899
|
+
*/
|
|
900
|
+
private credentialDeadCooldownMs;
|
|
877
901
|
private readonly client;
|
|
878
902
|
private readonly refreshMarginMs;
|
|
879
903
|
private accounts;
|
|
@@ -1018,7 +1042,9 @@ export declare class WorkBuddyAccountPool {
|
|
|
1018
1042
|
total: number;
|
|
1019
1043
|
cooling: number;
|
|
1020
1044
|
disabled: number;
|
|
1021
|
-
|
|
1045
|
+
/** Accounts skipped because the upstream rejected their sign-in. */
|
|
1046
|
+
dead: number;
|
|
1047
|
+
reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'session_dead' | 'none';
|
|
1022
1048
|
};
|
|
1023
1049
|
/** Round-robin: the legacy cursor walk, kept for the distribution that asks for it. */
|
|
1024
1050
|
private pickRoundRobin;
|
|
@@ -1135,12 +1161,37 @@ export declare class WorkBuddyAccountPool {
|
|
|
1135
1161
|
*/
|
|
1136
1162
|
lastServedId(): string | undefined;
|
|
1137
1163
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
1138
|
-
refreshAccount(accountId: string
|
|
1164
|
+
refreshAccount(accountId: string, options?: {
|
|
1165
|
+
force?: boolean;
|
|
1166
|
+
}): Promise<boolean>;
|
|
1167
|
+
/**
|
|
1168
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
1169
|
+
*
|
|
1170
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
1171
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
1172
|
+
* picks the same account again — its credential file still claims to be valid
|
|
1173
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
1174
|
+
* healthy accounts are never tried.
|
|
1175
|
+
*/
|
|
1176
|
+
penalizeCredentialDead(accountId: string): void;
|
|
1177
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
1178
|
+
clearCredentialDead(accountId: string): void;
|
|
1179
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
1180
|
+
deadCredentials(): readonly WorkBuddyAccount[];
|
|
1139
1181
|
/**
|
|
1140
1182
|
* Refresh the account's access token when it is within the margin (or already
|
|
1141
1183
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
1142
1184
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
1143
1185
|
* endpoint never takes down a working session.
|
|
1186
|
+
*
|
|
1187
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
1188
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
1189
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
1190
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
1191
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
1192
|
+
*
|
|
1193
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
1194
|
+
* valid), false when the credential was proven dead.
|
|
1144
1195
|
*/
|
|
1145
1196
|
private ensureFresh;
|
|
1146
1197
|
/**
|
|
@@ -1168,6 +1219,7 @@ export declare class WorkBuddyAccountPool {
|
|
|
1168
1219
|
status(): {
|
|
1169
1220
|
count: number;
|
|
1170
1221
|
cooling: number;
|
|
1222
|
+
dead: number;
|
|
1171
1223
|
lastScanAtMs: number;
|
|
1172
1224
|
};
|
|
1173
1225
|
}
|
|
@@ -1218,6 +1270,15 @@ interface PoolWebAccount {
|
|
|
1218
1270
|
cooling: boolean;
|
|
1219
1271
|
/** ISO timestamp when the account-wide 429 cooldown lifts; only while cooling. */
|
|
1220
1272
|
cooldownUntil?: string;
|
|
1273
|
+
/**
|
|
1274
|
+
* The upstream REJECTED this account's sign-in (401/403) — the credential file
|
|
1275
|
+
* still looks valid because the upstream never rewrites its expiry when it
|
|
1276
|
+
* revokes a token. Distinct from `cooling`: waiting does not fix it, only
|
|
1277
|
+
* signing in again in the desktop app does.
|
|
1278
|
+
*/
|
|
1279
|
+
credentialDead?: boolean;
|
|
1280
|
+
/** ISO timestamp when the dead mark expires and the account is retried; only while `credentialDead`. */
|
|
1281
|
+
credentialDeadUntil?: string;
|
|
1221
1282
|
/**
|
|
1222
1283
|
* Per-model cooldowns currently active. The account is NOT `cooling` while a
|
|
1223
1284
|
* model is limited — its other models still serve — but each entry tells the
|
package/lib/index.js
CHANGED
|
@@ -2752,6 +2752,28 @@ function idleWeight(lastUsedAt, now) {
|
|
|
2752
2752
|
}
|
|
2753
2753
|
/** Default rest for an account whose credits ran out (packs reset on their own schedule). */
|
|
2754
2754
|
const EXHAUST_COOLDOWN_MS = 18e5;
|
|
2755
|
+
/**
|
|
2756
|
+
* Default rest for an account the upstream rejected the sign-in of (401/403).
|
|
2757
|
+
*
|
|
2758
|
+
* Unlike a rate limit there is no reset time to read: only signing in again in
|
|
2759
|
+
* the desktop app revives the account. The rest is therefore long enough that a
|
|
2760
|
+
* single request never burns its whole retry budget on the same dead credential,
|
|
2761
|
+
* but short enough that a user who just signed in is not locked out of their own
|
|
2762
|
+
* account for long — and a newer credential file clears the mark outright.
|
|
2763
|
+
*/
|
|
2764
|
+
const CREDENTIAL_DEAD_COOLDOWN_MS = 18e5;
|
|
2765
|
+
/**
|
|
2766
|
+
* Does this refresh failure prove the credential itself is dead?
|
|
2767
|
+
*
|
|
2768
|
+
* The upstream answers a revoked refresh token with `invalid_grant: Offline user
|
|
2769
|
+
* session not found` (HTTP 400/401). Network failures, DNS errors and 5xx do NOT
|
|
2770
|
+
* prove anything about the credential and must not mark it dead — that would
|
|
2771
|
+
* take a working account out of rotation for half an hour over a hiccup.
|
|
2772
|
+
*/
|
|
2773
|
+
function isCredentialDeadError(error) {
|
|
2774
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2775
|
+
return message.includes("invalid_grant") || message.includes("Offline user session not found") || message.includes("session_dead");
|
|
2776
|
+
}
|
|
2755
2777
|
var WorkBuddyAccountPool = class {
|
|
2756
2778
|
logger;
|
|
2757
2779
|
authDirs;
|
|
@@ -2762,6 +2784,11 @@ var WorkBuddyAccountPool = class {
|
|
|
2762
2784
|
* rate-limit window, so this is much longer than `cooldownMs`.
|
|
2763
2785
|
*/
|
|
2764
2786
|
exhaustCooldownMs;
|
|
2787
|
+
/**
|
|
2788
|
+
* How long an account stays out of rotation after the upstream rejected its
|
|
2789
|
+
* sign-in. Cleared by a newer credential file or an explicit sign-in.
|
|
2790
|
+
*/
|
|
2791
|
+
credentialDeadCooldownMs;
|
|
2765
2792
|
client;
|
|
2766
2793
|
refreshMarginMs;
|
|
2767
2794
|
accounts = [];
|
|
@@ -2844,6 +2871,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2844
2871
|
this.authDirs = options.authDirs ?? candidateAuthDirs();
|
|
2845
2872
|
this.cooldownMs = options.cooldownMs ?? 6e4;
|
|
2846
2873
|
this.exhaustCooldownMs = options.exhaustCooldownMs ?? EXHAUST_COOLDOWN_MS;
|
|
2874
|
+
this.credentialDeadCooldownMs = options.credentialDeadCooldownMs ?? CREDENTIAL_DEAD_COOLDOWN_MS;
|
|
2847
2875
|
this.client = options.client;
|
|
2848
2876
|
this.refreshMarginMs = options.refreshMarginMs ?? 3e5;
|
|
2849
2877
|
this.distribution = options.distribution ?? "priority";
|
|
@@ -2930,11 +2958,15 @@ var WorkBuddyAccountPool = class {
|
|
|
2930
2958
|
});
|
|
2931
2959
|
continue;
|
|
2932
2960
|
}
|
|
2933
|
-
if (isFresher(credential, existing.credential))
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2961
|
+
if (isFresher(credential, existing.credential)) {
|
|
2962
|
+
const signedInAgain = credential.refreshToken !== existing.credential.refreshToken || credential.accessToken !== existing.credential.accessToken;
|
|
2963
|
+
byId.set(id, {
|
|
2964
|
+
...existing,
|
|
2965
|
+
credential,
|
|
2966
|
+
label: accountLabel(credential),
|
|
2967
|
+
...signedInAgain ? { credentialDeadUntilMs: 0 } : {}
|
|
2968
|
+
});
|
|
2969
|
+
}
|
|
2938
2970
|
}
|
|
2939
2971
|
const ordered = [...byId.values()];
|
|
2940
2972
|
ordered.sort((a, b) => compareFreshness(a.credential, b.credential));
|
|
@@ -2965,6 +2997,7 @@ var WorkBuddyAccountPool = class {
|
|
|
2965
2997
|
if (balance !== void 0 && balance <= reserve) return false;
|
|
2966
2998
|
}
|
|
2967
2999
|
if (account.cooldownUntilMs > now) return false;
|
|
3000
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) return false;
|
|
2968
3001
|
if (modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now) return false;
|
|
2969
3002
|
if (region !== void 0 && regionOf(account.credential.domain) !== region) return false;
|
|
2970
3003
|
return true;
|
|
@@ -2990,15 +3023,21 @@ var WorkBuddyAccountPool = class {
|
|
|
2990
3023
|
total: 0,
|
|
2991
3024
|
cooling: 0,
|
|
2992
3025
|
disabled: 0,
|
|
3026
|
+
dead: 0,
|
|
2993
3027
|
reason: "empty"
|
|
2994
3028
|
};
|
|
2995
3029
|
let cooling = 0;
|
|
2996
3030
|
let disabled = 0;
|
|
3031
|
+
let dead = 0;
|
|
2997
3032
|
for (const account of inRegion) {
|
|
2998
3033
|
if (this.disabledIds.has(account.id)) {
|
|
2999
3034
|
disabled += 1;
|
|
3000
3035
|
continue;
|
|
3001
3036
|
}
|
|
3037
|
+
if ((account.credentialDeadUntilMs ?? 0) > now) {
|
|
3038
|
+
dead += 1;
|
|
3039
|
+
continue;
|
|
3040
|
+
}
|
|
3002
3041
|
const modelCooling = modelId !== void 0 && (account.modelCooldowns[modelId] ?? 0) > now;
|
|
3003
3042
|
if (account.cooldownUntilMs > now || modelCooling) {
|
|
3004
3043
|
cooling += 1;
|
|
@@ -3011,26 +3050,37 @@ var WorkBuddyAccountPool = class {
|
|
|
3011
3050
|
total: inRegion.length,
|
|
3012
3051
|
cooling,
|
|
3013
3052
|
disabled,
|
|
3053
|
+
dead,
|
|
3014
3054
|
reason: "reserve"
|
|
3015
3055
|
};
|
|
3016
3056
|
}
|
|
3017
3057
|
}
|
|
3058
|
+
if (dead > 0) return {
|
|
3059
|
+
total: inRegion.length,
|
|
3060
|
+
cooling,
|
|
3061
|
+
disabled,
|
|
3062
|
+
dead,
|
|
3063
|
+
reason: "session_dead"
|
|
3064
|
+
};
|
|
3018
3065
|
if (cooling > 0) return {
|
|
3019
3066
|
total: inRegion.length,
|
|
3020
3067
|
cooling,
|
|
3021
3068
|
disabled,
|
|
3069
|
+
dead,
|
|
3022
3070
|
reason: "cooling"
|
|
3023
3071
|
};
|
|
3024
3072
|
if (disabled > 0) return {
|
|
3025
3073
|
total: inRegion.length,
|
|
3026
3074
|
cooling,
|
|
3027
3075
|
disabled,
|
|
3076
|
+
dead,
|
|
3028
3077
|
reason: "disabled"
|
|
3029
3078
|
};
|
|
3030
3079
|
return {
|
|
3031
3080
|
total: inRegion.length,
|
|
3032
3081
|
cooling,
|
|
3033
3082
|
disabled,
|
|
3083
|
+
dead,
|
|
3034
3084
|
reason: "none"
|
|
3035
3085
|
};
|
|
3036
3086
|
}
|
|
@@ -3134,31 +3184,28 @@ var WorkBuddyAccountPool = class {
|
|
|
3134
3184
|
*/
|
|
3135
3185
|
async acquire(modelId, region, conversationKey) {
|
|
3136
3186
|
if (this.accounts.length === 0) await this.scan();
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
if (this.preferredId !== void 0) {
|
|
3144
|
-
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3145
|
-
if (preferred !== void 0) {
|
|
3146
|
-
await this.ensureFresh(preferred);
|
|
3147
|
-
return preferred;
|
|
3187
|
+
const maxTries = this.accounts.length + 1;
|
|
3188
|
+
for (let attempt = 0; attempt < maxTries; attempt += 1) {
|
|
3189
|
+
let pool = this.available(Date.now(), modelId, region);
|
|
3190
|
+
if (pool.length === 0 && attempt === 0) {
|
|
3191
|
+
await this.scan();
|
|
3192
|
+
pool = this.available(Date.now(), modelId, region);
|
|
3148
3193
|
}
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3194
|
+
if (pool.length === 0) return void 0;
|
|
3195
|
+
if (this.preferredId !== void 0) {
|
|
3196
|
+
const preferred = pool.find((account) => account.id === this.preferredId);
|
|
3197
|
+
if (preferred !== void 0 && await this.ensureFresh(preferred)) return preferred;
|
|
3198
|
+
}
|
|
3199
|
+
if (this.distribution === "sticky") {
|
|
3200
|
+
const bound = this.affinityAccount(pool, conversationKey);
|
|
3201
|
+
if (bound !== void 0 && await this.ensureFresh(bound)) return bound;
|
|
3155
3202
|
}
|
|
3203
|
+
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3204
|
+
if (account === void 0) return void 0;
|
|
3205
|
+
if (!await this.ensureFresh(account)) continue;
|
|
3206
|
+
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3207
|
+
return account;
|
|
3156
3208
|
}
|
|
3157
|
-
const account = this.distribution === "round-robin" || this.distribution === "sticky" ? this.pickRoundRobin(pool) : this.distribution === "balanced" ? this.pickByWeight(pool) : pool[0];
|
|
3158
|
-
if (account === void 0) return void 0;
|
|
3159
|
-
if (this.distribution === "sticky" && conversationKey !== void 0 && conversationKey !== "") this.bindAffinity(conversationKey, account.id);
|
|
3160
|
-
await this.ensureFresh(account);
|
|
3161
|
-
return account;
|
|
3162
3209
|
}
|
|
3163
3210
|
/** Pin the account the plugin card should prefer; tokens stay out of settings. */
|
|
3164
3211
|
/** How the pool currently spreads requests. Shown on the card. */
|
|
@@ -3262,31 +3309,68 @@ var WorkBuddyAccountPool = class {
|
|
|
3262
3309
|
return newest?.id;
|
|
3263
3310
|
}
|
|
3264
3311
|
/** Best-effort refresh of one account after a session-dead upstream answer. */
|
|
3265
|
-
async refreshAccount(accountId) {
|
|
3312
|
+
async refreshAccount(accountId, options) {
|
|
3313
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3314
|
+
if (account === void 0) return false;
|
|
3315
|
+
return await this.ensureFresh(account, options?.force === true);
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Cool a whole account because the upstream REJECTED its sign-in (401/403).
|
|
3319
|
+
*
|
|
3320
|
+
* Called with direct evidence (the request just came back `session_dead`), so
|
|
3321
|
+
* it does not need to guess: without this, the next retry of the SAME request
|
|
3322
|
+
* picks the same account again — its credential file still claims to be valid
|
|
3323
|
+
* — and a request with 8 attempts spends all 8 on one dead account while
|
|
3324
|
+
* healthy accounts are never tried.
|
|
3325
|
+
*/
|
|
3326
|
+
penalizeCredentialDead(accountId) {
|
|
3327
|
+
const account = this.accounts.find((item) => item.id === accountId);
|
|
3328
|
+
if (account === void 0) return;
|
|
3329
|
+
const until = Date.now() + this.credentialDeadCooldownMs;
|
|
3330
|
+
account.credentialDeadUntilMs = Math.max(account.credentialDeadUntilMs ?? 0, until);
|
|
3331
|
+
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in was rejected by the upstream; keeping it out of rotation until ${new Date(until).toISOString()} (sign in again to restore it)`);
|
|
3332
|
+
}
|
|
3333
|
+
/** Put an account back in rotation after its sign-in was proven good again. */
|
|
3334
|
+
clearCredentialDead(accountId) {
|
|
3266
3335
|
const account = this.accounts.find((item) => item.id === accountId);
|
|
3267
3336
|
if (account === void 0) return;
|
|
3268
|
-
|
|
3337
|
+
account.credentialDeadUntilMs = 0;
|
|
3338
|
+
}
|
|
3339
|
+
/** Accounts currently kept out of rotation because their sign-in was rejected. */
|
|
3340
|
+
deadCredentials() {
|
|
3341
|
+
const now = Date.now();
|
|
3342
|
+
return this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now);
|
|
3269
3343
|
}
|
|
3270
3344
|
/**
|
|
3271
3345
|
* Refresh the account's access token when it is within the margin (or already
|
|
3272
3346
|
* expired), in-flight de-duped per account. A failed refresh keeps the
|
|
3273
3347
|
* existing token when it has not yet expired, so an unreachable refresh
|
|
3274
3348
|
* endpoint never takes down a working session.
|
|
3349
|
+
*
|
|
3350
|
+
* `force` skips the "is it expiring?" gate. That gate is a cost optimisation,
|
|
3351
|
+
* not a correctness rule: it exists so a healthy token is not re-fetched on
|
|
3352
|
+
* every acquire. After the upstream has ALREADY rejected the token, the gate
|
|
3353
|
+
* is actively wrong — a revoked token keeps its future `expiresAtMs` — and the
|
|
3354
|
+
* caller needs the refresh attempted so the credential can be proven dead.
|
|
3355
|
+
*
|
|
3356
|
+
* Returns true when the token is known good afterwards (refreshed, or still
|
|
3357
|
+
* valid), false when the credential was proven dead.
|
|
3275
3358
|
*/
|
|
3276
|
-
async ensureFresh(account) {
|
|
3277
|
-
if (this.client === void 0) return;
|
|
3359
|
+
async ensureFresh(account, force = false) {
|
|
3360
|
+
if (this.client === void 0) return true;
|
|
3278
3361
|
const credential = account.credential;
|
|
3279
|
-
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs)) return;
|
|
3362
|
+
if (!(credential.expiresAtMs <= 0 || credential.expiresAtMs <= Date.now() + this.refreshMarginMs) && !force) return true;
|
|
3280
3363
|
const existing = this.refreshInflight.get(account.id);
|
|
3281
3364
|
if (existing !== void 0) {
|
|
3282
3365
|
await existing;
|
|
3283
|
-
return;
|
|
3366
|
+
return (account.credentialDeadUntilMs ?? 0) <= Date.now();
|
|
3284
3367
|
}
|
|
3285
3368
|
const run = (async () => {
|
|
3286
3369
|
if (credential.refreshToken === "") {
|
|
3287
|
-
if (credential.expiresAtMs > Date.now() + 3e4) return;
|
|
3370
|
+
if (credential.expiresAtMs > Date.now() + 3e4) return true;
|
|
3288
3371
|
this.logger?.warn(`dsh-workbuddy-xdpool: ${account.label} token expired with no refresh token; sign in again`);
|
|
3289
|
-
|
|
3372
|
+
this.penalizeCredentialDead(account.id);
|
|
3373
|
+
return false;
|
|
3290
3374
|
}
|
|
3291
3375
|
try {
|
|
3292
3376
|
const outcome = await this.client.refreshToken(credential);
|
|
@@ -3297,15 +3381,23 @@ var WorkBuddyAccountPool = class {
|
|
|
3297
3381
|
expiresAtMs: outcome.expiresInSec !== void 0 ? Date.now() + outcome.expiresInSec * 1e3 : credential.expiresAtMs,
|
|
3298
3382
|
...outcome.domain === void 0 || outcome.domain === "" ? {} : { domain: outcome.domain }
|
|
3299
3383
|
};
|
|
3384
|
+
this.clearCredentialDead(account.id);
|
|
3300
3385
|
this.logger?.info?.(`dsh-workbuddy-xdpool: refreshed token for ${account.label}`);
|
|
3386
|
+
return true;
|
|
3301
3387
|
} catch (error) {
|
|
3388
|
+
if (isCredentialDeadError(error)) {
|
|
3389
|
+
this.penalizeCredentialDead(account.id);
|
|
3390
|
+
this.logger?.warn?.(`dsh-workbuddy-xdpool: ${account.label} refresh token was revoked upstream; sign in again to restore it`, error);
|
|
3391
|
+
return false;
|
|
3392
|
+
}
|
|
3302
3393
|
if (credential.expiresAtMs > Date.now() + 3e4) this.logger?.warn?.(`dsh-workbuddy-xdpool: token refresh failed but token still valid for ${account.label}`, error);
|
|
3303
3394
|
else this.logger?.error?.(`dsh-workbuddy-xdpool: token refresh failed and token expired for ${account.label}`, error);
|
|
3395
|
+
return true;
|
|
3304
3396
|
}
|
|
3305
3397
|
})();
|
|
3306
3398
|
this.refreshInflight.set(account.id, run);
|
|
3307
3399
|
try {
|
|
3308
|
-
await run;
|
|
3400
|
+
return await run;
|
|
3309
3401
|
} finally {
|
|
3310
3402
|
this.refreshInflight.delete(account.id);
|
|
3311
3403
|
}
|
|
@@ -3354,6 +3446,7 @@ ${new Date(until).toISOString()}
|
|
|
3354
3446
|
account.cooldownUntilMs = 0;
|
|
3355
3447
|
account.modelCooldowns = {};
|
|
3356
3448
|
account.rateLimitHits = 0;
|
|
3449
|
+
account.credentialDeadUntilMs = 0;
|
|
3357
3450
|
}
|
|
3358
3451
|
}
|
|
3359
3452
|
/** Diagnostics snapshot. Account-wide cooling count (per-model cooling excluded:
|
|
@@ -3363,6 +3456,7 @@ ${new Date(until).toISOString()}
|
|
|
3363
3456
|
return {
|
|
3364
3457
|
count: this.accounts.length,
|
|
3365
3458
|
cooling: this.accounts.filter((account) => account.cooldownUntilMs > now).length,
|
|
3459
|
+
dead: this.accounts.filter((account) => (account.credentialDeadUntilMs ?? 0) > now).length,
|
|
3366
3460
|
lastScanAtMs: this.lastScanAtMs
|
|
3367
3461
|
};
|
|
3368
3462
|
}
|
|
@@ -5900,6 +5994,10 @@ function createWorkBuddyShim(options) {
|
|
|
5900
5994
|
writeOpenAIError(res, 402, "reserve_floor", `every WorkBuddy account is at or below its credit reserve (${why.total} account(s)); lower the reserve on the pool card or top up credits`);
|
|
5901
5995
|
return;
|
|
5902
5996
|
}
|
|
5997
|
+
if (why.reason === "session_dead") {
|
|
5998
|
+
writeOpenAIError(res, 401, "session_dead", `${why.dead}/${why.total} WorkBuddy account(s) for this gateway were rejected by the upstream (their sign-in was revoked); sign in again in the WorkBuddy desktop app to restore them`);
|
|
5999
|
+
return;
|
|
6000
|
+
}
|
|
5903
6001
|
writeOpenAIError(res, 401, "not_signed_in", why.total === 0 ? "no WorkBuddy credential found for this gateway; sign in on the desktop app (or set WORKBUDDY_AUTH_FILE)" : `no usable WorkBuddy credential among ${why.total} account(s) for this gateway; sign in again on the desktop app`);
|
|
5904
6002
|
return;
|
|
5905
6003
|
}
|
|
@@ -5916,7 +6014,11 @@ function createWorkBuddyShim(options) {
|
|
|
5916
6014
|
};
|
|
5917
6015
|
if (result.kind === "session_dead") {
|
|
5918
6016
|
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} session dead; refreshing token and retrying`);
|
|
5919
|
-
|
|
6017
|
+
const before = account.credential.accessToken;
|
|
6018
|
+
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) {
|
|
6019
|
+
pool.penalizeCredentialDead(account.id);
|
|
6020
|
+
logger?.warn(`dsh-workbuddy-xdpool: ${account.label} sign-in is no longer valid (attempt ${attempt + 1}/${maxAttempts}); rotating`);
|
|
6021
|
+
}
|
|
5920
6022
|
continue;
|
|
5921
6023
|
}
|
|
5922
6024
|
if (result.kind === "hard_credit") {
|
|
@@ -6109,7 +6211,8 @@ async function recoverFromContextOverrun(options) {
|
|
|
6109
6211
|
detail: "prompt still exceeded the window after compaction"
|
|
6110
6212
|
};
|
|
6111
6213
|
if (result.kind === "session_dead") {
|
|
6112
|
-
|
|
6214
|
+
const before = account.credential.accessToken;
|
|
6215
|
+
if (!await pool.refreshAccount(account.id, { force: true }) || account.credential.accessToken === before) pool.penalizeCredentialDead(account.id);
|
|
6113
6216
|
continue;
|
|
6114
6217
|
}
|
|
6115
6218
|
if (result.kind === "hard_credit") {
|
|
@@ -6401,6 +6504,8 @@ function parseAutomationRun(body) {
|
|
|
6401
6504
|
function toWebAccount(account, disabled, reserve, reserved) {
|
|
6402
6505
|
const now = Date.now();
|
|
6403
6506
|
const cooling = account.cooldownUntilMs > now;
|
|
6507
|
+
const deadUntil = account.credentialDeadUntilMs ?? 0;
|
|
6508
|
+
const credentialDead = deadUntil > now;
|
|
6404
6509
|
const modelCooldowns = Object.entries(account.modelCooldowns).filter(([, until]) => until > now).sort((a, b) => a[1] - b[1]).map(([modelId, until]) => ({
|
|
6405
6510
|
modelId,
|
|
6406
6511
|
until: new Date(until).toISOString()
|
|
@@ -6413,6 +6518,8 @@ function toWebAccount(account, disabled, reserve, reserved) {
|
|
|
6413
6518
|
...account.credential.expiresAtMs === 0 ? {} : { expiresAt: new Date(account.credential.expiresAtMs).toISOString() },
|
|
6414
6519
|
cooling,
|
|
6415
6520
|
...cooling ? { cooldownUntil: new Date(account.cooldownUntilMs).toISOString() } : {},
|
|
6521
|
+
credentialDead,
|
|
6522
|
+
...credentialDead ? { credentialDeadUntil: new Date(deadUntil).toISOString() } : {},
|
|
6416
6523
|
...modelCooldowns.length === 0 ? {} : { modelCooldowns },
|
|
6417
6524
|
disabled,
|
|
6418
6525
|
creditReserve: reserve,
|
|
@@ -6452,7 +6559,7 @@ async function poolWebStatus(deps, region = "cn") {
|
|
|
6452
6559
|
const row = toWebAccount(account, deps.pool.isDisabled(account.id), deps.pool.creditReserveOf(account.id), deps.pool.isReserved(account.id));
|
|
6453
6560
|
const earned = deps.scheduler?.().earningsToday[account.id];
|
|
6454
6561
|
if (earned !== void 0) Object.assign(row, { automationToday: earned });
|
|
6455
|
-
if (!row.cooling) {
|
|
6562
|
+
if (!row.cooling && row.credentialDead !== true) {
|
|
6456
6563
|
try {
|
|
6457
6564
|
const credits = await deps.client.fetchCredits(account.credential);
|
|
6458
6565
|
deps.pool.noteCredits(account.id, credits.total);
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "dsh-workbuddy-xdpool",
|
|
3
3
|
"displayName": "DSH WorkBuddy XD Pool",
|
|
4
4
|
"description": "Merge every locally signed-in WorkBuddy account into DeepSeek Harness as one auto-failing-over model pool (multi-account rotation, live credits, daily check-in and model catalog).",
|
|
5
|
-
"version": "1.8.
|
|
5
|
+
"version": "1.8.1",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XDTrees",
|
|
8
8
|
"homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",
|