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 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)) byId.set(id, {
2826
- ...existing,
2827
- credential,
2828
- label: accountLabel(credential)
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
- let pool = this.available(Date.now(), modelId, region);
3030
- if (pool.length === 0) {
3031
- await this.scan();
3032
- pool = this.available(Date.now(), modelId, region);
3033
- }
3034
- if (pool.length === 0) return void 0;
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
- if (this.distribution === "sticky") {
3043
- const bound = this.affinityAccount(pool, conversationKey);
3044
- if (bound !== void 0) {
3045
- await this.ensureFresh(bound);
3046
- return bound;
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
- await this.ensureFresh(account);
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
- return;
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 = isCooling ? {
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
- children: account.creditsError
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
- reason: 'empty' | 'cooling' | 'disabled' | 'reserve' | 'none';
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): Promise<void>;
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)) byId.set(id, {
2934
- ...existing,
2935
- credential,
2936
- label: accountLabel(credential)
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
- let pool = this.available(Date.now(), modelId, region);
3138
- if (pool.length === 0) {
3139
- await this.scan();
3140
- pool = this.available(Date.now(), modelId, region);
3141
- }
3142
- if (pool.length === 0) return void 0;
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
- if (this.distribution === "sticky") {
3151
- const bound = this.affinityAccount(pool, conversationKey);
3152
- if (bound !== void 0) {
3153
- await this.ensureFresh(bound);
3154
- return bound;
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
- await this.ensureFresh(account);
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
- return;
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
- await pool.refreshAccount(account.id);
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
- await pool.refreshAccount(account.id);
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.0",
5
+ "version": "1.8.1",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-workbuddy-xdpool#readme",