quotacap 0.0.44 → 0.0.46
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/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +11 -6
- package/dist/adapters/kimi.d.ts +7 -0
- package/dist/adapters/kimi.js +43 -9
- package/dist/advisory/snapshot.js +7 -4
- package/dist/diagnostics/failure.js +29 -1
- package/dist/runtime/poll.d.ts +13 -0
- package/dist/runtime/poll.js +124 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface PollAllOptions {
|
|
|
25
25
|
export declare function computeRetryDelayMs(failedAttempt: number, baseMs?: number, capMs?: number, rng?: () => number): number;
|
|
26
26
|
/** Order-preserving concurrency-limited map. */
|
|
27
27
|
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
28
|
+
export declare const RETRYABLE_DIAGNOSTIC_CODES: ReadonlySet<string>;
|
|
28
29
|
export declare function isRetryableChildFailure(reason: unknown): boolean;
|
|
29
30
|
export declare function pollAll(enabled: string[], opts?: PollAllOptions): Promise<({
|
|
30
31
|
provider: string;
|
package/dist/adapters/index.js
CHANGED
|
@@ -56,13 +56,18 @@ export async function mapWithConcurrency(items, limit, fn) {
|
|
|
56
56
|
await Promise.all(workers);
|
|
57
57
|
return results;
|
|
58
58
|
}
|
|
59
|
-
// A child that ran and produced a diagnosable output problem is worth
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
59
|
+
// A child that ran and produced a diagnosable output problem is worth more
|
|
60
|
+
// attempts; a login wall, missing binary, trust prompt, or rate limit will
|
|
61
|
+
// not clear on retry, and a mid-flight child death (killAll, crash) must
|
|
62
|
+
// never respawn — especially during shutdown. Shared with the coordinator,
|
|
63
|
+
// which consults it for both in-generation retries and recovery polls.
|
|
64
|
+
export const RETRYABLE_DIAGNOSTIC_CODES = new Set([
|
|
65
|
+
"parse_error",
|
|
66
|
+
"network",
|
|
67
|
+
"service_unavailable",
|
|
68
|
+
]);
|
|
64
69
|
export function isRetryableChildFailure(reason) {
|
|
65
|
-
return
|
|
70
|
+
return RETRYABLE_DIAGNOSTIC_CODES.has(classifyFailure("Provider", reason).diagnosticCode);
|
|
66
71
|
}
|
|
67
72
|
async function pollOnceWithGate(id, adapter, timeout) {
|
|
68
73
|
// One controller per attempt: a timeout aborts only that attempt's
|
package/dist/adapters/kimi.d.ts
CHANGED
|
@@ -35,6 +35,13 @@ export declare function refreshKimiOAuthToken(oauthHost: string, refreshToken: s
|
|
|
35
35
|
refresh_token?: string;
|
|
36
36
|
expires_in?: number;
|
|
37
37
|
}>;
|
|
38
|
+
/** The Kimi account has no active subscription (observed: cancelled plan
|
|
39
|
+
* answers /usages 200 with `{}`, /me `user_level_name` "Free", and the TUI
|
|
40
|
+
* shows "No usage data available"). The TUI reports the same state, so the
|
|
41
|
+
* PTY fallback is skipped: report it, do not mask it with a timeout. */
|
|
42
|
+
export declare class KimiSubscriptionInactiveError extends Error {
|
|
43
|
+
constructor(detail: string);
|
|
44
|
+
}
|
|
38
45
|
/** A refresh rotated the tokens but the result could not be saved for the Kimi CLI. */
|
|
39
46
|
export declare class KimiCredentialWriteError extends Error {
|
|
40
47
|
constructor(credPath: string, cause: unknown);
|
package/dist/adapters/kimi.js
CHANGED
|
@@ -153,6 +153,14 @@ function parseKimiApiMonthly(u, legacyUsages) {
|
|
|
153
153
|
}
|
|
154
154
|
return {};
|
|
155
155
|
}
|
|
156
|
+
function isEmptyUsageBody(u) {
|
|
157
|
+
const usage = u.usage;
|
|
158
|
+
const hasUsage = !!usage && typeof usage === "object" && Object.keys(usage).length > 0;
|
|
159
|
+
const hasLimits = Array.isArray(u.limits) && u.limits.length > 0;
|
|
160
|
+
const usages = u.usages;
|
|
161
|
+
const hasUsages = !!usages && typeof usages === "object" && Object.keys(usages).length > 0;
|
|
162
|
+
return !hasUsage && !hasLimits && !hasUsages;
|
|
163
|
+
}
|
|
156
164
|
export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
157
165
|
if (typeof usagesBody !== "object" || usagesBody === null) {
|
|
158
166
|
throw new Error("kimi: invalid api response");
|
|
@@ -212,6 +220,21 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
|
212
220
|
}
|
|
213
221
|
}
|
|
214
222
|
}
|
|
223
|
+
// 3. Plan identity (from meBody), before the field throws: an empty
|
|
224
|
+
// usage body on a free plan is a dead subscription, not a parse failure.
|
|
225
|
+
let plan = "unknown";
|
|
226
|
+
if (meBody && typeof meBody === "object") {
|
|
227
|
+
const me = meBody;
|
|
228
|
+
if (typeof me.user_level_name === "string" && me.user_level_name.trim().length > 0) {
|
|
229
|
+
plan = me.user_level_name.trim().toLowerCase();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (weeklyPct === null && sessionPct === null && !resetsAt && isEmptyUsageBody(u) && plan === "free") {
|
|
233
|
+
throw new KimiSubscriptionInactiveError("plan free, empty usage response");
|
|
234
|
+
}
|
|
235
|
+
// Any other shape without data stays a parse error: an empty body on a
|
|
236
|
+
// paid or unknown plan falls through to the PTY path, whose transcript
|
|
237
|
+
// decides (usage panel vs "No usage data available").
|
|
215
238
|
if (weeklyPct === null || weeklyPct < 0 || weeklyPct > 100) {
|
|
216
239
|
throw new Error("kimi: weekly pct not found in api response");
|
|
217
240
|
}
|
|
@@ -221,14 +244,6 @@ export function parseKimiApiUsage(usagesBody, meBody, now = new Date()) {
|
|
|
221
244
|
if (!resetsAt || Number.isNaN(new Date(resetsAt).getTime())) {
|
|
222
245
|
throw new Error("kimi: weekly reset not found in api response");
|
|
223
246
|
}
|
|
224
|
-
// 3. Plan identity (from meBody)
|
|
225
|
-
let plan = "unknown";
|
|
226
|
-
if (meBody && typeof meBody === "object") {
|
|
227
|
-
const me = meBody;
|
|
228
|
-
if (typeof me.user_level_name === "string" && me.user_level_name.trim().length > 0) {
|
|
229
|
-
plan = me.user_level_name.trim().toLowerCase();
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
247
|
// 4. Optional monthly quota (from limit_month_total, limits, or subscriptionBalance)
|
|
233
248
|
const monthly = parseKimiApiMonthly(u, legacyUsages);
|
|
234
249
|
const periodStart = new Date(new Date(resetsAt).getTime() - 7 * 86400000).toISOString();
|
|
@@ -377,6 +392,16 @@ export async function refreshKimiOAuthToken(oauthHost, refreshToken, deviceId, s
|
|
|
377
392
|
expires_in: typeof data.expires_in === "number" ? data.expires_in : undefined,
|
|
378
393
|
};
|
|
379
394
|
}
|
|
395
|
+
/** The Kimi account has no active subscription (observed: cancelled plan
|
|
396
|
+
* answers /usages 200 with `{}`, /me `user_level_name` "Free", and the TUI
|
|
397
|
+
* shows "No usage data available"). The TUI reports the same state, so the
|
|
398
|
+
* PTY fallback is skipped: report it, do not mask it with a timeout. */
|
|
399
|
+
export class KimiSubscriptionInactiveError extends Error {
|
|
400
|
+
constructor(detail) {
|
|
401
|
+
super(`kimi: subscription inactive (${detail})`);
|
|
402
|
+
this.name = "KimiSubscriptionInactiveError";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
380
405
|
/** A refresh rotated the tokens but the result could not be saved for the Kimi CLI. */
|
|
381
406
|
export class KimiCredentialWriteError extends Error {
|
|
382
407
|
constructor(credPath, cause) {
|
|
@@ -463,6 +488,11 @@ export async function pollKimiApi(ctx) {
|
|
|
463
488
|
headers.Authorization = `Bearer ${freshToken}`;
|
|
464
489
|
usagesRes = await fetch(usagesUrl, { headers, signal });
|
|
465
490
|
}
|
|
491
|
+
// 402/403 on an authenticated /usages call is an entitlement state, not
|
|
492
|
+
// a transport failure: the account has no active subscription.
|
|
493
|
+
if (usagesRes.status === 402 || usagesRes.status === 403) {
|
|
494
|
+
throw new KimiSubscriptionInactiveError(`HTTP ${usagesRes.status}`);
|
|
495
|
+
}
|
|
466
496
|
if (!usagesRes.ok) {
|
|
467
497
|
throw new Error(`kimi: usage request failed (HTTP ${usagesRes.status})`);
|
|
468
498
|
}
|
|
@@ -520,9 +550,13 @@ export const kimiAdapter = {
|
|
|
520
550
|
return await pollKimiApi(authCtx);
|
|
521
551
|
}
|
|
522
552
|
catch (e) {
|
|
523
|
-
// A lost rotated token leaves the CLI logged out
|
|
553
|
+
// A lost rotated token leaves the CLI logged out, and a dead
|
|
554
|
+
// subscription reads the same in the TUI: report either, do not
|
|
555
|
+
// mask them with the PTY path.
|
|
524
556
|
if (e instanceof KimiCredentialWriteError)
|
|
525
557
|
throw e;
|
|
558
|
+
if (e instanceof KimiSubscriptionInactiveError)
|
|
559
|
+
throw e;
|
|
526
560
|
}
|
|
527
561
|
}
|
|
528
562
|
return pollKimiPty();
|
|
@@ -97,7 +97,10 @@ export function buildSnapshot(db, opts) {
|
|
|
97
97
|
providerFailed = true;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
// Precedence: invalid,
|
|
100
|
+
// Precedence: invalid, provider-failed, reset-passed, stale, not-reporting.
|
|
101
|
+
// A fresh failure outranks a passed reset: "poll again to capture the new
|
|
102
|
+
// window" is only actionable when polls succeed, and would otherwise mask
|
|
103
|
+
// the real state (e.g. an inactive subscription) behind a re-poll prompt.
|
|
101
104
|
let exclusionReason = null;
|
|
102
105
|
if (quota === null) {
|
|
103
106
|
exclusionReason = "not-reporting";
|
|
@@ -105,12 +108,12 @@ export function buildSnapshot(db, opts) {
|
|
|
105
108
|
else if (isInvalid) {
|
|
106
109
|
exclusionReason = "invalid";
|
|
107
110
|
}
|
|
108
|
-
else if (resetPassed) {
|
|
109
|
-
exclusionReason = "reset-passed";
|
|
110
|
-
}
|
|
111
111
|
else if (providerFailed) {
|
|
112
112
|
exclusionReason = "provider-failed";
|
|
113
113
|
}
|
|
114
|
+
else if (resetPassed) {
|
|
115
|
+
exclusionReason = "reset-passed";
|
|
116
|
+
}
|
|
114
117
|
else if (stale) {
|
|
115
118
|
exclusionReason = "stale";
|
|
116
119
|
}
|
|
@@ -86,6 +86,15 @@ const UNRECOGNIZED_OMITTED = "Unrecognized diagnostic text omitted";
|
|
|
86
86
|
// "account is not eligible" is the Antigravity sentence; a bare "not eligible" is not.
|
|
87
87
|
// The hyphen covers "Sign-in with". "signed in with" does not match.
|
|
88
88
|
const ACCOUNT_CONFIRMATION = /\b(?:account is not eligible|verify your account|eligibility check failed|device code|sign(?:ing)?[ -]in with)\b/i;
|
|
89
|
+
// Cancelled- or missing-subscription wording. The QuotaCap-authored phrase
|
|
90
|
+
// ("subscription inactive", thrown by adapters that detect it directly) plus
|
|
91
|
+
// Kimi's own sentences for the same state: the /usage panel shows "No usage
|
|
92
|
+
// data available" and prompting fails with "[provider.auth_error] 403 ...
|
|
93
|
+
// does not have access to Kimi Code ... Upgrade your plan". Bare "does not
|
|
94
|
+
// have access" is not enough: it must name Kimi Code, so file-permission
|
|
95
|
+
// errors never match. A bare "Upgrade your plan" is not enough either: it
|
|
96
|
+
// can be upsell banner text on an otherwise healthy screen.
|
|
97
|
+
const SUBSCRIPTION_INACTIVE = /\bsubscription inactive\b|\bno usage data available\b|\bprovider\.auth_error\b|\bdoes not have access to Kimi Code\b/i;
|
|
89
98
|
function matchPrecedence(evidence, errorName, errorCode, text) {
|
|
90
99
|
// Order 1: timeout — Structured AbortError or an explicit QuotaCap abort/timeout checkpoint.
|
|
91
100
|
// A ready or completion timeout whose transcript is an account-confirmation
|
|
@@ -93,7 +102,13 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
|
|
|
93
102
|
// abort stays a timeout even if that screen is in the transcript.
|
|
94
103
|
const signInTimeout = ACCOUNT_CONFIRMATION.test(text) &&
|
|
95
104
|
(evidence?.checkpoint === "ready timeout" || evidence?.checkpoint === "completion timeout");
|
|
105
|
+
// Same fall-through for a dead subscription: a completion timeout whose
|
|
106
|
+
// stable transcript is the empty usage panel reports the subscription
|
|
107
|
+
// state, not the timeout that a missing completion pattern caused.
|
|
108
|
+
const inactiveTimeout = SUBSCRIPTION_INACTIVE.test(text) &&
|
|
109
|
+
(evidence?.checkpoint === "ready timeout" || evidence?.checkpoint === "completion timeout");
|
|
96
110
|
const isAbort = !signInTimeout &&
|
|
111
|
+
!inactiveTimeout &&
|
|
97
112
|
(errorName === "AbortError" ||
|
|
98
113
|
errorCode === "ABORT_ERR" ||
|
|
99
114
|
evidence?.checkpoint === "abort" ||
|
|
@@ -158,6 +173,12 @@ function matchPrecedence(evidence, errorName, errorCode, text) {
|
|
|
158
173
|
}
|
|
159
174
|
// Order 6: auth — login required, not logged in, logged out, unauthorized, unauthorised, forbidden,
|
|
160
175
|
// run <provider> login to continue, or error indicating missing, invalid, or expired credentials, authentication, or API key
|
|
176
|
+
// A dead subscription sorts as auth (the 403 is an authorization state),
|
|
177
|
+
// first in this order so its summary wins over login wording: the user is
|
|
178
|
+
// logged in, and "login required" would mislead.
|
|
179
|
+
if (SUBSCRIPTION_INACTIVE.test(text)) {
|
|
180
|
+
return { code: "auth", phrase: "subscription inactive" };
|
|
181
|
+
}
|
|
161
182
|
if (/\brun\s+[a-z0-9_:-]+\s+login\s+to\s+continue\b/i.test(text)) {
|
|
162
183
|
return { code: "auth", phrase: "run <provider> login to continue" };
|
|
163
184
|
}
|
|
@@ -424,7 +445,8 @@ export function diagnosticError(reason, evidence) {
|
|
|
424
445
|
const matchedLines = lines.filter((l) => /\b(error|failed|failure|fatal|unauthorized|unauthorised|denied|forbidden|panic|exception)\b/i.test(l) ||
|
|
425
446
|
/\btrust\b/i.test(l) ||
|
|
426
447
|
/\b(stdin is not a terminal|device not configured|not a tty|inappropriate ioctl|node-pty)\b/i.test(l) ||
|
|
427
|
-
ACCOUNT_CONFIRMATION.test(l)
|
|
448
|
+
ACCOUNT_CONFIRMATION.test(l) ||
|
|
449
|
+
SUBSCRIPTION_INACTIVE.test(l));
|
|
428
450
|
safeStdoutText = matchedLines.join(" ");
|
|
429
451
|
}
|
|
430
452
|
else {
|
|
@@ -497,6 +519,12 @@ function getSummaryAndAction(code, providerName, provider, errorDetail) {
|
|
|
497
519
|
action: `Open ${providerName} directly in your terminal to check whether it starts. If it works there, inspect the QuotaCap service log and report the adapter failure. Refresh in the dashboard retries through the same service.`,
|
|
498
520
|
};
|
|
499
521
|
case "auth":
|
|
522
|
+
if (/\bsubscription inactive\b/i.test(errorDetail)) {
|
|
523
|
+
return {
|
|
524
|
+
summary: `${providerName} subscription inactive`,
|
|
525
|
+
action: `Open ${providerName} to manage your subscription, or run \`quotacap providers disable ${provider}\` to stop polling it. Then select Refresh in the QuotaCap dashboard.`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
500
528
|
if (/\baccount confirmation required\b/i.test(errorDetail)) {
|
|
501
529
|
return {
|
|
502
530
|
summary: `${providerName} needs you to confirm the subscription account`,
|
package/dist/runtime/poll.d.ts
CHANGED
|
@@ -34,6 +34,15 @@ export interface CoordinatorState {
|
|
|
34
34
|
lastCompletedPollAt: string | null;
|
|
35
35
|
lastResult: RefreshResult | null;
|
|
36
36
|
}
|
|
37
|
+
export interface RecoveryOptions {
|
|
38
|
+
/** First recovery delay. Default 60000. Doubles per consecutive failure. */
|
|
39
|
+
baseDelayMs?: number;
|
|
40
|
+
/** Recovery delay ceiling. Default 600000. */
|
|
41
|
+
maxDelayMs?: number;
|
|
42
|
+
/** Stop auto-recovery after this many consecutive failures per provider.
|
|
43
|
+
* Scheduled ticks keep trying; a success resets the count. Default 5. */
|
|
44
|
+
maxFailures?: number;
|
|
45
|
+
}
|
|
37
46
|
export interface CoordinatorOptions {
|
|
38
47
|
db: any;
|
|
39
48
|
enabledProviders: string[];
|
|
@@ -48,6 +57,10 @@ export interface CoordinatorOptions {
|
|
|
48
57
|
dataDir?: string;
|
|
49
58
|
/** Opt-in forensics: write a redacted evidence bundle per poll failure. */
|
|
50
59
|
debugFailureBundles?: boolean;
|
|
60
|
+
/** Auto-recovery: re-poll providers whose window expired or went stale
|
|
61
|
+
* instead of waiting for the next scheduled tick. Enabled by default;
|
|
62
|
+
* pass false to disable. */
|
|
63
|
+
recovery?: false | RecoveryOptions;
|
|
51
64
|
}
|
|
52
65
|
export interface Coordinator {
|
|
53
66
|
refresh(): Promise<RefreshResult>;
|
package/dist/runtime/poll.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Poll coordinator: scheduled and manual refresh share one in-flight poll
|
|
2
2
|
// with a completion-measured cooldown (decisions D3/D4).
|
|
3
|
-
import { pollAll, ADAPTER_TIMEOUTS } from "../adapters/index.js";
|
|
4
|
-
import { getAllLatest, upsertQuota } from "../store/quotas.js";
|
|
3
|
+
import { pollAll, ADAPTER_TIMEOUTS, RETRYABLE_DIAGNOSTIC_CODES } from "../adapters/index.js";
|
|
4
|
+
import { getAllLatest, getLatestByProvider, upsertQuota } from "../store/quotas.js";
|
|
5
|
+
import { STALE_MS } from "../advisory/snapshot.js";
|
|
5
6
|
import { readServiceMetadata } from "../config.js";
|
|
6
7
|
import { writeFailureBundle, providerBin, getCliVersion } from "../diagnostics/bundle.js";
|
|
7
8
|
import { VERSION } from "../version.js";
|
|
@@ -56,6 +57,11 @@ export function createCoordinator(opts) {
|
|
|
56
57
|
const onSettled = opts.onSettled;
|
|
57
58
|
const dataDir = opts.dataDir;
|
|
58
59
|
const debugFailureBundles = opts.debugFailureBundles === true;
|
|
60
|
+
const recoveryEnabled = opts.recovery !== false;
|
|
61
|
+
const recoveryOpts = typeof opts.recovery === "object" ? opts.recovery : {};
|
|
62
|
+
const recoveryBaseMs = recoveryOpts.baseDelayMs ?? 60000;
|
|
63
|
+
const recoveryMaxMs = recoveryOpts.maxDelayMs ?? 600000;
|
|
64
|
+
const recoveryMaxFailures = recoveryOpts.maxFailures ?? 5;
|
|
59
65
|
let inFlight = null;
|
|
60
66
|
let lastResult = null;
|
|
61
67
|
let lastCompletedPollAt = null;
|
|
@@ -64,6 +70,9 @@ export function createCoordinator(opts) {
|
|
|
64
70
|
let started = false;
|
|
65
71
|
let intervalMs = 0;
|
|
66
72
|
let timer = null;
|
|
73
|
+
let recoveryTimer = null;
|
|
74
|
+
const recoveryFailures = new Map();
|
|
75
|
+
const recoveryExhausted = new Set();
|
|
67
76
|
function arm() {
|
|
68
77
|
if (timer)
|
|
69
78
|
clearTimeout(timer);
|
|
@@ -81,9 +90,9 @@ export function createCoordinator(opts) {
|
|
|
81
90
|
enabledProviders,
|
|
82
91
|
}));
|
|
83
92
|
}
|
|
84
|
-
async function runGeneration() {
|
|
93
|
+
async function runGeneration(ids = enabledProviders) {
|
|
85
94
|
const attemptedAt = new Date(now()).toISOString();
|
|
86
|
-
const rows = await pollFn(
|
|
95
|
+
const rows = await pollFn(ids);
|
|
87
96
|
const completedAt = new Date(now()).toISOString();
|
|
88
97
|
// Self-fence before any poll write (decision D1).
|
|
89
98
|
if (ownershipVerify && !ownershipVerify()) {
|
|
@@ -202,12 +211,117 @@ export function createCoordinator(opts) {
|
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
213
|
}
|
|
214
|
+
// Partial (recovery) generations merge into the previous full result so
|
|
215
|
+
// a refresh response never drops providers that were not re-polled.
|
|
216
|
+
if (lastResult && ids.length < enabledProviders.length) {
|
|
217
|
+
const idsSet = new Set(ids);
|
|
218
|
+
result.fulfilled = [
|
|
219
|
+
...lastResult.fulfilled.filter((f) => !idsSet.has(f?.provider)),
|
|
220
|
+
...result.fulfilled,
|
|
221
|
+
];
|
|
222
|
+
result.rejected = [
|
|
223
|
+
...lastResult.rejected.filter((r) => !idsSet.has(r.provider)),
|
|
224
|
+
...result.rejected,
|
|
225
|
+
];
|
|
226
|
+
result.results = [
|
|
227
|
+
...lastResult.results.filter((r) => !idsSet.has(r.provider)),
|
|
228
|
+
...result.results,
|
|
229
|
+
];
|
|
230
|
+
result.degraded = result.rejected.length > 0;
|
|
231
|
+
}
|
|
205
232
|
lastResult = result;
|
|
206
233
|
lastCompletedPollAt = completedAt;
|
|
207
234
|
completedAtMs = new Date(completedAt).getTime();
|
|
235
|
+
// Recovery bookkeeping before re-arming: an attempt is an attempt,
|
|
236
|
+
// whether it came from the schedule, a manual refresh, or recovery.
|
|
237
|
+
for (const id of ids) {
|
|
238
|
+
if (needsRecovery(id)) {
|
|
239
|
+
recoveryFailures.set(id, (recoveryFailures.get(id) ?? 0) + 1);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
recoveryFailures.delete(id);
|
|
243
|
+
recoveryExhausted.delete(id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
evaluateRecovery();
|
|
208
247
|
return result;
|
|
209
248
|
}
|
|
210
|
-
|
|
249
|
+
/** A provider needs recovery when its row is expired but a fresh poll
|
|
250
|
+
* could plausibly fix it: reset passed or stale, and the last failure —
|
|
251
|
+
* if any — is a retryable or timeout cause, never auth or a hard wall. */
|
|
252
|
+
function needsRecovery(id) {
|
|
253
|
+
if (id === "manual")
|
|
254
|
+
return false;
|
|
255
|
+
const quota = getLatestByProvider(db, id);
|
|
256
|
+
if (!quota)
|
|
257
|
+
return false;
|
|
258
|
+
const nowMs = now();
|
|
259
|
+
const resetsMs = new Date(quota.resetsAt).getTime();
|
|
260
|
+
const fetchedMs = new Date(quota.fetchedAt).getTime();
|
|
261
|
+
const resetPassed = !Number.isNaN(resetsMs) && resetsMs <= nowMs;
|
|
262
|
+
const stale = !Number.isNaN(fetchedMs) && nowMs - fetchedMs > STALE_MS;
|
|
263
|
+
if (!resetPassed && !stale)
|
|
264
|
+
return false;
|
|
265
|
+
const attempt = getAttempt(db, id);
|
|
266
|
+
if (attempt && !attempt.success && attempt.diagnosticCode) {
|
|
267
|
+
const code = attempt.diagnosticCode;
|
|
268
|
+
if (code !== "timeout" && !RETRYABLE_DIAGNOSTIC_CODES.has(code))
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
function evaluateRecovery() {
|
|
274
|
+
if (recoveryTimer) {
|
|
275
|
+
clearTimeout(recoveryTimer);
|
|
276
|
+
recoveryTimer = null;
|
|
277
|
+
}
|
|
278
|
+
if (closing || !recoveryEnabled)
|
|
279
|
+
return;
|
|
280
|
+
const needy = enabledProviders.filter((id) => needsRecovery(id) && (recoveryFailures.get(id) ?? 0) < recoveryMaxFailures);
|
|
281
|
+
for (const id of enabledProviders) {
|
|
282
|
+
if (needsRecovery(id) && (recoveryFailures.get(id) ?? 0) >= recoveryMaxFailures) {
|
|
283
|
+
if (!recoveryExhausted.has(id)) {
|
|
284
|
+
recoveryExhausted.add(id);
|
|
285
|
+
console.log(`[quotacap] [${id}] recovery exhausted; waiting for the next scheduled poll`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (needy.length === 0)
|
|
290
|
+
return;
|
|
291
|
+
const worst = Math.max(...needy.map((id) => recoveryFailures.get(id) ?? 0));
|
|
292
|
+
const delay = Math.min(recoveryMaxMs, recoveryBaseMs * 2 ** worst);
|
|
293
|
+
console.log(`[quotacap] [${needy.join(", ")}] scheduling recovery poll in ${Math.round(delay / 1000)}s`);
|
|
294
|
+
recoveryTimer = setTimeout(() => {
|
|
295
|
+
recoveryTimer = null;
|
|
296
|
+
void fireRecovery(needy);
|
|
297
|
+
}, delay);
|
|
298
|
+
}
|
|
299
|
+
async function fireRecovery(ids) {
|
|
300
|
+
if (closing)
|
|
301
|
+
return;
|
|
302
|
+
// Self-fence on wake, like scheduledTick: do not spawn doomed children.
|
|
303
|
+
if (ownershipVerify && !ownershipVerify()) {
|
|
304
|
+
closing = true;
|
|
305
|
+
onOwnershipLost();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const targets = ids.filter((id) => needsRecovery(id));
|
|
309
|
+
if (targets.length === 0) {
|
|
310
|
+
evaluateRecovery();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
await (inFlight ?? launch(targets));
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
const detail = classifyFailure("all", e).errorDetail;
|
|
318
|
+
console.warn("[quotacap] recovery poll failed", detail);
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
evaluateRecovery();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function launch(ids = enabledProviders) {
|
|
211
325
|
// Gate first: inFlight is set before pollFn runs, so getState()
|
|
212
326
|
// reports in-progress from inside the poll itself.
|
|
213
327
|
let resolve;
|
|
@@ -222,7 +336,7 @@ export function createCoordinator(opts) {
|
|
|
222
336
|
inFlight = null;
|
|
223
337
|
};
|
|
224
338
|
gate.then(clear, clear);
|
|
225
|
-
runGeneration().then(resolve, reject);
|
|
339
|
+
runGeneration(ids).then(resolve, reject);
|
|
226
340
|
return gate;
|
|
227
341
|
}
|
|
228
342
|
function refresh() {
|
|
@@ -281,6 +395,10 @@ export function createCoordinator(opts) {
|
|
|
281
395
|
clearTimeout(timer);
|
|
282
396
|
timer = null;
|
|
283
397
|
}
|
|
398
|
+
if (recoveryTimer) {
|
|
399
|
+
clearTimeout(recoveryTimer);
|
|
400
|
+
recoveryTimer = null;
|
|
401
|
+
}
|
|
284
402
|
}
|
|
285
403
|
function getState() {
|
|
286
404
|
const polling = inFlight !== null
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// generated by scripts/build-embed.mjs — do not edit
|
|
2
|
-
export const VERSION = "0.0.
|
|
2
|
+
export const VERSION = "0.0.46";
|