@teamvibe/poller 0.8.6-rc.2 → 0.8.6-rc.4
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/claude-quota.js +13 -6
- package/dist/claude-spawner.js +39 -6
- package/dist/poller.js +5 -2
- package/dist/pool-logging.d.ts +12 -0
- package/dist/pool-logging.js +27 -13
- package/dist/pool-telemetry.d.ts +2 -0
- package/dist/pool-telemetry.js +20 -1
- package/dist/subscription-pool.d.ts +8 -2
- package/dist/subscription-pool.js +2 -2
- package/dist/subscription-runtime.d.ts +14 -0
- package/dist/subscription-runtime.js +94 -70
- package/dist/subscription-selection.d.ts +17 -1
- package/dist/subscription-selection.js +154 -10
- package/dist/subscription-usage-store.d.ts +2 -0
- package/dist/subscription-usage-store.js +3 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/claude-quota.js
CHANGED
|
@@ -21,10 +21,11 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
21
21
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
22
|
SOFTWARE.
|
|
23
23
|
*/
|
|
24
|
+
import { normalizePassiveUsage, projectLegacyFableUsage } from './subscription-selection.js';
|
|
24
25
|
// Selectively adapted from Paperclip AI (MIT), Copyright (c) 2025 Paperclip AI.
|
|
25
26
|
// https://github.com/paperclipai/paperclip/blob/cc88b61c0918f45f6d3215f5d2d2368c88297046/packages/adapters/claude-local/src/server/parse.ts#L24-L30
|
|
26
27
|
// Excludes generic rate limits, extra-usage suggestions and AWS service quotas.
|
|
27
|
-
const QUOTA_RE = /(?:you(?:'|’)ve\s+hit\s+your\s+(?:(?:usage|weekly|session|sonnet|opus|haiku)\s+)?limit\b(?!\s+of\b)|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|(?:claude\s+)?usage\s+(?:limit|cap)\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached)/i;
|
|
28
|
+
const QUOTA_RE = /(?:you(?:'|’)ve\s+hit\s+your\s+(?:(?:usage|weekly|session|sonnet|opus|haiku|fable)\s+)?limit\b(?!\s+of\b)|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|(?:claude\s+)?usage\s+(?:limit|cap)\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached)/i;
|
|
28
29
|
function record(value) {
|
|
29
30
|
return value !== null && typeof value === 'object' ? value : {};
|
|
30
31
|
}
|
|
@@ -82,27 +83,33 @@ export function parseClaudeQuotaFailure(input, now) {
|
|
|
82
83
|
: /out\s+of\s+extra\s+usage/i.test(evidence) ? 'extra_usage'
|
|
83
84
|
: 'usage_limit';
|
|
84
85
|
// Scope is adjacent to this exhausted limit, never unrelated advice.
|
|
85
|
-
const model = diagnostic.slice(evidence.length).match(/^\s+(?:for|on)\s+(?:model\s+)?((?:claude-)?(?:sonnet|opus|haiku)(?:-\d+)*)(?![\w-])/i)?.[1]?.toLowerCase()
|
|
86
|
-
?? evidence.match(/your\s+((?:claude-)?(?:sonnet|opus|haiku)(?:-\d+)*)\s+limit/i)?.[1]?.toLowerCase();
|
|
86
|
+
const model = diagnostic.slice(evidence.length).match(/^\s+(?:for|on)\s+(?:model\s+)?((?:claude-)?(?:sonnet|opus|haiku|fable)(?:-\d+)*)(?![\w-])/i)?.[1]?.toLowerCase()
|
|
87
|
+
?? evidence.match(/your\s+((?:claude-)?(?:sonnet|opus|haiku|fable)(?:-\d+)*)\s+limit/i)?.[1]?.toLowerCase();
|
|
87
88
|
const window = reason === 'weekly_limit' ? 'seven_day'
|
|
88
89
|
: reason === 'session_limit' || /5[-\s]?hour/i.test(evidence) ? 'five_hour' : undefined;
|
|
89
90
|
const resetPrefix = diagnostic.slice(evidence.length).split(/\bresets?\b/i)[0] ?? '';
|
|
90
91
|
// Another named window/model before "resets" makes that reset unrelated
|
|
91
92
|
// or ambiguous. Strip only the directly attached model scope first.
|
|
92
|
-
const scopePrefix = resetPrefix.replace(/^\s+(?:for|on)\s+(?:model\s+)?(?:claude-)?(?:sonnet|opus|haiku)(?:-\d+)*(?![\w-])/i, '');
|
|
93
|
-
const unrelatedAllowance = /\b(?:5[-\s]?hour|five[-\s]?hour|weekly|seven[-\s]?day|session|(?:claude-)?(?:sonnet|opus|haiku)(?:-\d+)*)\b/i.test(scopePrefix);
|
|
93
|
+
const scopePrefix = resetPrefix.replace(/^\s+(?:for|on)\s+(?:model\s+)?(?:claude-)?(?:sonnet|opus|haiku|fable)(?:-\d+)*(?![\w-])/i, '');
|
|
94
|
+
const unrelatedAllowance = /\b(?:5[-\s]?hour|five[-\s]?hour|weekly|seven[-\s]?day|session|(?:claude-)?(?:sonnet|opus|haiku|fable)(?:-\d+)*)\b/i.test(scopePrefix);
|
|
94
95
|
const resetAt = unrelatedAllowance ? undefined : parseResetAt(diagnostic, now);
|
|
95
96
|
return [{ reason, ...(window ? { window } : {}), ...(model ? { modelScope: model } : {}), ...(resetAt ? { resetAt } : {}) }];
|
|
96
97
|
});
|
|
97
98
|
}));
|
|
98
99
|
// Detailed weekly evidence beats a generic message, but its unknown reset
|
|
99
100
|
// stays unknown; no search through the other candidates for a timestamp.
|
|
100
|
-
|
|
101
|
+
let quota = candidates.find(candidate => candidate.reason === 'weekly_limit') ?? candidates[0];
|
|
101
102
|
if (!quota)
|
|
102
103
|
return undefined;
|
|
103
104
|
// Global windows cannot establish the reset of a model-specific allowance.
|
|
104
105
|
// Prefer structured metadata for the exact text-classified window only.
|
|
105
106
|
const info = input.rateLimitInfo;
|
|
107
|
+
if (info?.status === 'rejected' && !quota.modelScope) {
|
|
108
|
+
const windows = projectLegacyFableUsage(normalizePassiveUsage(info, now.getTime())).windows;
|
|
109
|
+
const rejected = windows.find(w => w.status === 'rejected');
|
|
110
|
+
if (rejected && rejected.scope !== 'global' && rejected.scope !== 'uncertain' && (!quota.window || quota.window === rejected.window))
|
|
111
|
+
quota = { ...quota, window: rejected.window, modelScope: rejected.scope, ...(rejected.resetAt && rejected.resetAt > now.getTime() ? { resetAt: new Date(rejected.resetAt).toISOString() } : {}) };
|
|
112
|
+
}
|
|
106
113
|
if (quota.window && !quota.modelScope && info) {
|
|
107
114
|
const matching = info.unifiedWindows?.[quota.window];
|
|
108
115
|
// An explicitly present window with an unknown reset stays unknown;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { blockingUsageWindows, normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
|
|
1
2
|
import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
|
|
2
|
-
import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
|
|
3
|
+
import { emitPoolSelection, accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
|
|
3
4
|
import { spawn } from 'child_process';
|
|
4
5
|
import { join } from 'path';
|
|
5
6
|
import { readFileSync, existsSync } from 'fs';
|
|
@@ -1046,6 +1047,8 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
|
|
|
1046
1047
|
activeProcessesByThread.set(msg.thread_id, proc);
|
|
1047
1048
|
if (runtime?.account)
|
|
1048
1049
|
activeSubscriptionAccounts.set(proc, runtime.account);
|
|
1050
|
+
if (proc.pid !== undefined)
|
|
1051
|
+
runtime?.onSpawn?.();
|
|
1049
1052
|
for (const chunk of spawnStdinPlan.stdinChunks) {
|
|
1050
1053
|
proc.stdin?.write(chunk);
|
|
1051
1054
|
}
|
|
@@ -1579,7 +1582,31 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1579
1582
|
const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog, { cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent, log: line => sessionLog.info(line) }) : undefined;
|
|
1580
1583
|
const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
|
|
1581
1584
|
? scoped?.modelNormalizedFrom : undefined;
|
|
1582
|
-
const runtime = poolRuntime ? {
|
|
1585
|
+
const runtime = poolRuntime ? {
|
|
1586
|
+
resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account,
|
|
1587
|
+
shouldStop: poolRuntime.shouldStop,
|
|
1588
|
+
resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true,
|
|
1589
|
+
onSpawn: () => {
|
|
1590
|
+
if (prepared?.selection) {
|
|
1591
|
+
emitPoolSelection(msg, prepared.selection);
|
|
1592
|
+
// Retries report each launch, but release the original pin only once.
|
|
1593
|
+
delete prepared.selection.switch;
|
|
1594
|
+
}
|
|
1595
|
+
},
|
|
1596
|
+
} : undefined;
|
|
1597
|
+
const rejectedForModel = (info) => {
|
|
1598
|
+
if (!prepared?.account || info?.status !== 'rejected')
|
|
1599
|
+
return false;
|
|
1600
|
+
const now = Date.now();
|
|
1601
|
+
return blockingUsageWindows(prepared.account, { usage: normalizePassiveUsage(info, now) }, resolved.model, now).some(w => w.status === 'rejected');
|
|
1602
|
+
};
|
|
1603
|
+
const quotaForModel = (quota) => {
|
|
1604
|
+
if (!quota || !prepared?.account)
|
|
1605
|
+
return quota;
|
|
1606
|
+
const now = Date.now();
|
|
1607
|
+
const selected = selectSubscriptionAccount({ pool: { kind: 'ready', accounts: [prepared.account] }, states: { [prepared.account.accountId]: { reactive: [{ observedAt: now, quota }] } }, model: resolved.model, now });
|
|
1608
|
+
return selected.kind === 'deferred' ? quota : undefined;
|
|
1609
|
+
};
|
|
1583
1610
|
let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
|
|
1584
1611
|
const attempt = async (id, first) => {
|
|
1585
1612
|
if (prepared?.account && id && poolRuntime) {
|
|
@@ -1595,8 +1622,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1595
1622
|
if (!runtime?.resumePending)
|
|
1596
1623
|
sawSendMessageCall = false;
|
|
1597
1624
|
const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
|
|
1598
|
-
const result = prepared?.account
|
|
1599
|
-
? { ...spawned, success: false } : spawned;
|
|
1625
|
+
const result = prepared?.account
|
|
1626
|
+
? { ...spawned, quota: quotaForModel(spawned.quota), success: rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
|
|
1600
1627
|
sawSendMessageCall ||= result.sawSendMessageCall === true;
|
|
1601
1628
|
if (prepared?.account) {
|
|
1602
1629
|
if (poolAttempts.length >= 1000)
|
|
@@ -1630,7 +1657,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1630
1657
|
retainWorktree = false;
|
|
1631
1658
|
return result;
|
|
1632
1659
|
}
|
|
1633
|
-
if ((result.quota || result.rateLimitInfo
|
|
1660
|
+
if ((result.quota || rejectedForModel(result.rateLimitInfo)) && !result.killedByPoller) {
|
|
1634
1661
|
runtime.resumePending = true;
|
|
1635
1662
|
retainWorktree = true;
|
|
1636
1663
|
}
|
|
@@ -1654,7 +1681,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1654
1681
|
let switched = false;
|
|
1655
1682
|
const finishQuota = async (initial) => {
|
|
1656
1683
|
let result = initial;
|
|
1657
|
-
while (prepared?.account && (result.quota || result.rateLimitInfo
|
|
1684
|
+
while (prepared?.account && (result.quota || rejectedForModel(result.rateLimitInfo)) && !result.killedByPoller && poolRuntime && runtime) {
|
|
1658
1685
|
attemptedAccounts.add(prepared.account.accountId);
|
|
1659
1686
|
if (poolRuntime.shouldStop())
|
|
1660
1687
|
throw new PoolDeferredError('infrastructure', { suppressNotice: true });
|
|
@@ -1689,6 +1716,12 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
|
|
|
1689
1716
|
abortLocalProbe(selected.account);
|
|
1690
1717
|
poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
|
|
1691
1718
|
prepared.account = selected.account;
|
|
1719
|
+
selected.candidates = [
|
|
1720
|
+
...(selected.candidates ?? []).filter(c => !attemptedAccounts.has(c.accountId)),
|
|
1721
|
+
...(prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => attemptedAccounts.has(a.accountId)).map(a => ({ accountId: a.accountId, accountLabel: JSON.parse(poolLabel(a)), outcome: 'skipped', reason: 'already exhausted in this session' })) : []),
|
|
1722
|
+
];
|
|
1723
|
+
if (prepared.pool.kind === 'ready' && prepared.pool.accounts.some(a => a.accountId === fromAccount.accountId))
|
|
1724
|
+
selected.switch = { fromAccountId: fromAccount.accountId, reason: 'reactive quota' };
|
|
1692
1725
|
prepared.selection = selected;
|
|
1693
1726
|
runtime.account = selected.account;
|
|
1694
1727
|
runtime.resumePending = true;
|
package/dist/poller.js
CHANGED
|
@@ -11,7 +11,7 @@ import { spawnClaudeCode, spawnPoolClaudeCode, isAtCapacity, getActiveProcessCou
|
|
|
11
11
|
import { sendSlackError, sendSlackMessage, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
|
|
12
12
|
import { classifyFailedIteration } from './classify-failed-iteration.js';
|
|
13
13
|
import { fetchSecretsForSpawn } from './secrets-client.js';
|
|
14
|
-
import { PoolDeferredError, fetchRuntimePool, subscriptionUsageStore } from './subscription-runtime.js';
|
|
14
|
+
import { PoolDeferredError, fetchRuntimePool, subscriptionUsageStore, HELD_PROBE_INTERVAL_MS } from './subscription-runtime.js';
|
|
15
15
|
import { POOL_RECHECK_MS } from './subscription-selection.js';
|
|
16
16
|
import { isPoolDeferred, readPoolDefer, deferPool, setPoolNotice, clearPoolDefer } from './pool-defer.js';
|
|
17
17
|
import { isQuotaPaused, pauseForQuota, clearQuotaPauseAfterSuccess, QuotaPausedError } from './quota-pause.js';
|
|
@@ -617,7 +617,10 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
|
|
|
617
617
|
}
|
|
618
618
|
quotaPaused = true;
|
|
619
619
|
deferred = true;
|
|
620
|
-
|
|
620
|
+
// Revisit held work hourly so background probes can refresh the account.
|
|
621
|
+
// Selection still enforces each window's real reset before inference.
|
|
622
|
+
const recheckAt = error.cause === 'usage' ? Math.min(error.eligibleAt, Date.now() + HELD_PROBE_INTERVAL_MS) : error.eligibleAt;
|
|
623
|
+
const pause = deferPool(threadId, recheckAt, error.resetAt);
|
|
621
624
|
const retained = readInboxRuntime(kbPath, threadId);
|
|
622
625
|
// Rechecks of one exhaustion episode do not inflate the episode count.
|
|
623
626
|
quotaDeferEpisodes = Math.max(1, quotaDeferEpisodes);
|
package/dist/pool-logging.d.ts
CHANGED
|
@@ -14,6 +14,18 @@ export declare function poolErrorClass(error: unknown): string;
|
|
|
14
14
|
/** Allowlisted class/code only: caught messages and arbitrary names may contain secrets. */
|
|
15
15
|
export declare function poolErrorDiagnostic(error: unknown): string;
|
|
16
16
|
export declare function logPoolFetch(log: PoolInfoLogger, pool: PoolFetchResult & PoolFetchDiagnostics): void;
|
|
17
|
+
export interface PoolSelectionCandidate {
|
|
18
|
+
accountId: string;
|
|
19
|
+
accountLabel: string;
|
|
20
|
+
outcome: 'selected' | 'skipped';
|
|
21
|
+
reason: string;
|
|
22
|
+
score?: number;
|
|
23
|
+
headroom?: number;
|
|
24
|
+
rateWindow?: Pick<UsageWindow, 'window' | 'scope' | 'percent'>;
|
|
25
|
+
eligibleAt?: number;
|
|
26
|
+
}
|
|
27
|
+
/** The log and transport share a single explanation of the same selection snapshot. */
|
|
28
|
+
export declare function poolSelectionCandidates<A extends LabeledAccount>(pool: PoolFetchResult<A>, states: Readonly<Record<string, AccountUsageState>>, model: string | undefined, now: number, selected: SelectionOutcome<A>): PoolSelectionCandidate[];
|
|
17
29
|
export declare function logPoolSelection<A extends LabeledAccount>(log: PoolInfoLogger, pool: PoolFetchResult<A>, states: Readonly<Record<string, AccountUsageState>>, model: string | undefined, now: number, selected: SelectionOutcome<A>, logAllExhausted?: boolean): void;
|
|
18
30
|
export declare function formatPoolUsageWindows(windows: readonly UsageWindow[], includeScope?: boolean): string;
|
|
19
31
|
/** Log the real store promise without changing its rejection or retry behavior. */
|
package/dist/pool-logging.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { redactStartupText } from './redact-text.js';
|
|
2
|
-
import { normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
|
|
2
|
+
import { normalizePassiveUsage, selectSubscriptionAccount, blockingUsageWindows } from './subscription-selection.js';
|
|
3
3
|
import { safeRateLimitInfo } from './subscription-usage-store.js';
|
|
4
4
|
// Never serialize accounts, credentials, provider strings or caught errors.
|
|
5
5
|
// Labels have already been checked by the pool client; JSON quoting keeps
|
|
@@ -42,23 +42,37 @@ export function logPoolFetch(log, pool) {
|
|
|
42
42
|
const configured = pool.kind === 'ready' || pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts' ? 'true' : 'unknown';
|
|
43
43
|
poolInfo(log, `fetch poolConfigured=${configured} enabled=${enabled} usable=${enabled} result=${pool.kind}${pool.kind === 'fetch_unavailable' ? ` reason=${pool.reason}` : ''}${pool.cause ? ` cause=${pool.cause}` : ''} attempts=${pool.attempts ?? 1}`);
|
|
44
44
|
}
|
|
45
|
-
|
|
45
|
+
/** The log and transport share a single explanation of the same selection snapshot. */
|
|
46
|
+
export function poolSelectionCandidates(pool, states, model, now, selected) {
|
|
46
47
|
if (pool.kind !== 'ready')
|
|
47
|
-
return;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
48
|
+
return [];
|
|
49
|
+
return pool.accounts.map(account => {
|
|
50
|
+
const base = { accountId: account.accountId, accountLabel: JSON.parse(poolLabel(account)), outcome: 'skipped' };
|
|
51
|
+
if (!account.enabled)
|
|
52
|
+
return { ...base, reason: 'disabled' };
|
|
53
53
|
const candidate = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states, model, now });
|
|
54
54
|
if (candidate.kind === 'deferred') {
|
|
55
55
|
const usageOnly = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states: { [account.accountId]: { usage: states[account.accountId]?.usage } }, model, now });
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
else if (candidate.kind === 'selected' && selected.kind === 'selected' && account.accountId !== selected.account.accountId) {
|
|
59
|
-
const reason = candidate.provenance === 'unknown' && selected.provenance === 'known' ? 'known observation preferred' : candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score < selected.score ? 'lower headroom per hour' : 'deterministic account order';
|
|
60
|
-
poolInfo(log, `skipped account=${poolLabel(account)} reason=${reason}`);
|
|
56
|
+
const window = blockingUsageWindows(account, states[account.accountId], model, now)[0];
|
|
57
|
+
return { ...base, ...(window ? { rateWindow: { window: window.window, scope: window.scope, percent: window.percent } } : {}), reason: usageOnly.kind === 'deferred' ? `usage >=${account.useFullQuota ? 100 : 90}%` : 'reactive quota', eligibleAt: candidate.eligibleAt };
|
|
61
58
|
}
|
|
59
|
+
const metrics = candidate.kind === 'selected' && candidate.provenance === 'known'
|
|
60
|
+
? { score: candidate.score, headroom: (account.useFullQuota ? 100 : 90) - candidate.rateWindow.percent, rateWindow: { window: candidate.rateWindow.window, scope: candidate.rateWindow.scope, percent: candidate.rateWindow.percent } } : {};
|
|
61
|
+
if (selected.kind === 'selected' && account.accountId === selected.account.accountId)
|
|
62
|
+
return { ...base, ...metrics, outcome: 'selected', reason: 'selected' };
|
|
63
|
+
const reason = candidate.kind === 'selected' && selected.kind === 'selected'
|
|
64
|
+
? candidate.provenance === 'unknown' && selected.provenance === 'known' ? 'known observation preferred' : candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score < selected.score ? 'lower headroom per hour' : 'deterministic account order'
|
|
65
|
+
: 'unavailable';
|
|
66
|
+
return { ...base, ...metrics, reason };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function logPoolSelection(log, pool, states, model, now, selected, logAllExhausted = true) {
|
|
70
|
+
if (pool.kind !== 'ready')
|
|
71
|
+
return;
|
|
72
|
+
for (const candidate of poolSelectionCandidates(pool, states, model, now, selected)) {
|
|
73
|
+
if (candidate.outcome !== 'skipped' || candidate.reason === 'unavailable')
|
|
74
|
+
continue;
|
|
75
|
+
poolInfo(log, `skipped account=${JSON.stringify(candidate.accountLabel)} reason=${candidate.reason}${candidate.eligibleAt !== undefined && candidate.rateWindow ? ` window=${candidate.rateWindow.window}/${candidate.rateWindow.scope}` : ''}${candidate.eligibleAt === undefined ? '' : ` eligibleAt=${new Date(candidate.eligibleAt).toISOString()}`}`);
|
|
62
76
|
}
|
|
63
77
|
if (selected.kind === 'deferred' && logAllExhausted) {
|
|
64
78
|
// Ask the same policy about each observation so model scope, freshness and
|
package/dist/pool-telemetry.d.ts
CHANGED
|
@@ -60,3 +60,5 @@ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quota
|
|
|
60
60
|
model?: string | undefined;
|
|
61
61
|
observedAt?: number | undefined;
|
|
62
62
|
};
|
|
63
|
+
/** Labels and explicit decision metrics only; never spread account or credential objects. */
|
|
64
|
+
export declare function emitPoolSelection(msg: TeamVibeQueueMessage, selection: import('./subscription-pool.js').ResolvedPoolSelection<SubscriptionAccount>): void;
|
package/dist/pool-telemetry.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { emitEvent } from './event-queue.js';
|
|
2
|
-
import { reactiveEligibleAt } from './subscription-selection.js';
|
|
2
|
+
import { family, reactiveEligibleAt } from './subscription-selection.js';
|
|
3
3
|
import { usageStateForSelection, safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
|
|
4
4
|
function safeText(value) {
|
|
5
5
|
return value && value.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(value)
|
|
@@ -123,3 +123,22 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
|
|
|
123
123
|
attempts: safeAttempts,
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
|
+
// Keep ordering across retries and intervening threads even within one clock tick.
|
|
127
|
+
let lastSelectionAt = 0;
|
|
128
|
+
/** Labels and explicit decision metrics only; never spread account or credential objects. */
|
|
129
|
+
export function emitPoolSelection(msg, selection) {
|
|
130
|
+
if (selection.kind !== 'selected')
|
|
131
|
+
return;
|
|
132
|
+
lastSelectionAt = Math.max(Date.now(), lastSelectionAt + 1);
|
|
133
|
+
emitEvent('pool.selection', msg.teamvibe.channelId, {
|
|
134
|
+
workspaceId: msg.teamvibe.workspaceId,
|
|
135
|
+
threadId: msg.thread_id,
|
|
136
|
+
model: safeText(selection.model) ?? 'CLI default',
|
|
137
|
+
modelFamily: family(selection.model) ?? 'default',
|
|
138
|
+
selectedAccountId: selection.account.accountId,
|
|
139
|
+
selectedAccountLabel: safeText(selection.account.label) ?? 'unlabeled',
|
|
140
|
+
decidedAt: lastSelectionAt,
|
|
141
|
+
candidates: selection.candidates ?? [],
|
|
142
|
+
...(selection.switch ? { switch: selection.switch } : {}),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type PoolInfoLogger } from './pool-logging.js';
|
|
1
|
+
import { type PoolSelectionCandidate, type PoolInfoLogger } from './pool-logging.js';
|
|
2
2
|
import type { resolveModel } from './claude-spawner.js';
|
|
3
3
|
import { type AccountIdentity, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
|
|
4
4
|
import { type SubscriptionUsageStore } from './subscription-usage-store.js';
|
|
@@ -6,7 +6,13 @@ export type ResolvedPoolModel = ReturnType<typeof resolveModel>;
|
|
|
6
6
|
export type ResolvedPoolSelection<A extends AccountIdentity> = (SelectionOutcome<A> | {
|
|
7
7
|
kind: 'usage_unavailable';
|
|
8
8
|
error: unknown;
|
|
9
|
-
}) & ResolvedPoolModel
|
|
9
|
+
}) & ResolvedPoolModel & {
|
|
10
|
+
candidates?: PoolSelectionCandidate[];
|
|
11
|
+
switch?: {
|
|
12
|
+
fromAccountId: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
10
16
|
/**
|
|
11
17
|
* #405 integration seam: resolveModel ONCE before calling, then reuse the
|
|
12
18
|
* returned model/modelSource for --model. No fetch, probe, spawn or env fallback
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logger } from './logger.js';
|
|
2
|
-
import { logPoolSelection, poolInfo, poolLabel, poolErrorClass } from './pool-logging.js';
|
|
2
|
+
import { logPoolSelection, poolSelectionCandidates, poolInfo, poolLabel, poolErrorClass } from './pool-logging.js';
|
|
3
3
|
import { selectSubscriptionAccount } from './subscription-selection.js';
|
|
4
4
|
import { usageStateForSelection } from './subscription-usage-store.js';
|
|
5
5
|
/**
|
|
@@ -33,5 +33,5 @@ export async function selectPoolForResolvedModel(resolvedModel, input) {
|
|
|
33
33
|
const selected = selectSubscriptionAccount({ pool: input.pool, states, model: resolvedModel.model, now: input.now, currentAccountId: input.currentAccountId, currentAccountTolerance: input.currentAccountTolerance });
|
|
34
34
|
if (input.log)
|
|
35
35
|
logPoolSelection(input.log, input.pool, states, resolvedModel.model, input.now, selected, input.logAllExhausted);
|
|
36
|
-
return { ...selected, ...resolvedModel };
|
|
36
|
+
return { ...selected, ...resolvedModel, candidates: poolSelectionCandidates(input.pool, states, resolvedModel.model, input.now, selected) };
|
|
37
37
|
}
|
|
@@ -54,6 +54,8 @@ export declare function subscriptionUsageStore(): FileSubscriptionUsageStore;
|
|
|
54
54
|
export declare function fetchRuntimePool(msg: TeamVibeQueueMessage): Promise<PoolFetchResult<SubscriptionAccount> & PoolFetchDiagnostics>;
|
|
55
55
|
/** Minimum gap between local measurements of one workspace/account/credential. */
|
|
56
56
|
export declare const LOCAL_PROBE_MIN_INTERVAL_MS = 60000;
|
|
57
|
+
/** Refresh held accounts without turning quota rejection into a busy loop. */
|
|
58
|
+
export declare const HELD_PROBE_INTERVAL_MS: number;
|
|
57
59
|
/** Bound background CLI load across every workspace in this poller process. */
|
|
58
60
|
export declare const LOCAL_PROBE_MAX_CONCURRENT = 3;
|
|
59
61
|
/** A local measurement has its own deadline, independent of session lifetime. */
|
|
@@ -89,6 +91,12 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
|
|
|
89
91
|
} & HeadroomScore & {
|
|
90
92
|
model: string | undefined;
|
|
91
93
|
modelSource: import("./claude-spawner.js").ModelSource;
|
|
94
|
+
} & {
|
|
95
|
+
candidates?: import("./pool-logging.js").PoolSelectionCandidate[];
|
|
96
|
+
switch?: {
|
|
97
|
+
fromAccountId: string;
|
|
98
|
+
reason: string;
|
|
99
|
+
};
|
|
92
100
|
}) | ({
|
|
93
101
|
kind: "selected";
|
|
94
102
|
account: SubscriptionAccount;
|
|
@@ -97,6 +105,12 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
|
|
|
97
105
|
} & {
|
|
98
106
|
model: string | undefined;
|
|
99
107
|
modelSource: import("./claude-spawner.js").ModelSource;
|
|
108
|
+
} & {
|
|
109
|
+
candidates?: import("./pool-logging.js").PoolSelectionCandidate[];
|
|
110
|
+
switch?: {
|
|
111
|
+
fromAccountId: string;
|
|
112
|
+
reason: string;
|
|
113
|
+
};
|
|
100
114
|
});
|
|
101
115
|
} | {
|
|
102
116
|
account: undefined;
|
|
@@ -10,7 +10,7 @@ import { getPollerId } from './auth-provider.js';
|
|
|
10
10
|
import { fetchSubscriptionPool } from './subscription-pool-client.js';
|
|
11
11
|
import { selectPoolForResolvedModel } from './subscription-pool.js';
|
|
12
12
|
import { FileSubscriptionUsageStore, usageStateForSelection } from './subscription-usage-store.js';
|
|
13
|
-
import { normalizePassiveUsage, selectSubscriptionAccount,
|
|
13
|
+
import { normalizePassiveUsage, selectSubscriptionAccount, blockingUsageWindows, usageWindowExpiresAt, PIN_HEADROOM_SWITCH_RELATIVE, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
|
|
14
14
|
export const POOL_UNAVAILABLE_RECHECK_MS = 60_000;
|
|
15
15
|
export class PoolDeferredError extends Error {
|
|
16
16
|
cause;
|
|
@@ -37,6 +37,8 @@ export async function fetchRuntimePool(msg) {
|
|
|
37
37
|
}
|
|
38
38
|
/** Minimum gap between local measurements of one workspace/account/credential. */
|
|
39
39
|
export const LOCAL_PROBE_MIN_INTERVAL_MS = 60_000;
|
|
40
|
+
/** Refresh held accounts without turning quota rejection into a busy loop. */
|
|
41
|
+
export const HELD_PROBE_INTERVAL_MS = 60 * 60_000;
|
|
40
42
|
/** Bound background CLI load across every workspace in this poller process. */
|
|
41
43
|
export const LOCAL_PROBE_MAX_CONCURRENT = 3;
|
|
42
44
|
/** A local measurement has its own deadline, independent of session lifetime. */
|
|
@@ -53,64 +55,87 @@ export function abortLocalProbe(account) {
|
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
57
|
function probeUnknownAccounts(accounts, selectedAccountId, resolved, store, options, channelId) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// session signal/shouldStop is passed: the observation outlives its trigger.
|
|
61
|
-
void (async () => {
|
|
62
|
-
const key = JSON.stringify([account.workspaceId, account.accountId, account.credentialVersion]);
|
|
63
|
-
let probe;
|
|
58
|
+
void (async () => {
|
|
59
|
+
const candidates = await Promise.all(accounts
|
|
60
|
+
.filter(account => account.enabled && account.accountId !== selectedAccountId && !isSubscriptionAccountRunning(account))
|
|
61
|
+
.map(async (account) => {
|
|
64
62
|
try {
|
|
65
|
-
|
|
66
|
-
const state = usageStateForSelection(await store.read(account), now);
|
|
67
|
-
const candidate = selectSubscriptionAccount({ pool: { kind: 'ready', accounts: [account] }, states: { [account.accountId]: state }, model: resolved.model, now });
|
|
68
|
-
if (candidate.kind !== 'selected' || candidate.provenance !== 'unknown')
|
|
69
|
-
return;
|
|
70
|
-
if (isSubscriptionAccountRunning(account))
|
|
71
|
-
return;
|
|
72
|
-
if (localProbesInFlight.has(key)) {
|
|
73
|
-
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=skipped reason=claimed`);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
if (options.isAtCapacity() || localProbesInFlight.size >= LOCAL_PROBE_MAX_CONCURRENT)
|
|
77
|
-
return;
|
|
78
|
-
// Reserve before asynchronous claiming, so concurrent preparePool calls
|
|
79
|
-
// cannot exceed the process-wide limit while claims are being written.
|
|
80
|
-
probe = { account, controller: new AbortController() };
|
|
81
|
-
localProbesInFlight.set(key, probe);
|
|
82
|
-
const claimed = await store.claimLocalProbe(account, now, LOCAL_PROBE_MIN_INTERVAL_MS);
|
|
83
|
-
if (probe.controller.signal.aborted) {
|
|
84
|
-
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=cancelled reason=account selected`);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (!claimed) {
|
|
88
|
-
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=skipped reason=claimed`);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
// Capacity may change while the durable claim is written.
|
|
92
|
-
if (options.isAtCapacity()) {
|
|
93
|
-
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=cli_at_capacity`);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
await runUsageProbe(account, { ...options, model: resolved.model, timeoutMs: LOCAL_PROBE_TIMEOUT_MS }, {
|
|
97
|
-
channelId, signal: probe.controller.signal,
|
|
98
|
-
onMeasurementComplete: () => {
|
|
99
|
-
if (localProbesInFlight.get(key) === probe)
|
|
100
|
-
localProbesInFlight.delete(key);
|
|
101
|
-
},
|
|
102
|
-
});
|
|
63
|
+
return { account, stored: await store.read(account) };
|
|
103
64
|
}
|
|
104
65
|
catch {
|
|
105
|
-
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result
|
|
106
|
-
|
|
107
|
-
finally {
|
|
108
|
-
// An aborted run must not delete a newer reservation for the same key.
|
|
109
|
-
if (probe && localProbesInFlight.get(key) === probe)
|
|
110
|
-
localProbesInFlight.delete(key);
|
|
66
|
+
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=unknown`);
|
|
67
|
+
return undefined;
|
|
111
68
|
}
|
|
112
|
-
})
|
|
113
|
-
|
|
69
|
+
}));
|
|
70
|
+
// Failed attempts still consume a durable claim. Prefer the oldest attempt
|
|
71
|
+
// so overflow eventually gets a slot, including after a poller restart.
|
|
72
|
+
const ordered = candidates.filter(candidate => candidate !== undefined)
|
|
73
|
+
.sort((a, b) => (a.stored.localProbeClaimedAt ?? 0) - (b.stored.localProbeClaimedAt ?? 0));
|
|
74
|
+
for (const { account, stored } of ordered) {
|
|
75
|
+
// Fire-and-forget ALL probe work, including disk claims and cleanup. No
|
|
76
|
+
// session signal/shouldStop is passed: the observation outlives its trigger.
|
|
77
|
+
void (async () => {
|
|
78
|
+
const key = JSON.stringify([account.workspaceId, account.accountId, account.credentialVersion]);
|
|
79
|
+
let probe;
|
|
80
|
+
try {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const state = usageStateForSelection(stored, now);
|
|
83
|
+
const candidate = selectSubscriptionAccount({ pool: { kind: 'ready', accounts: [account] }, states: { [account.accountId]: state }, model: resolved.model, now });
|
|
84
|
+
const held = selectSubscriptionAccount({ pool: { kind: 'ready', accounts: [account] }, states: { [account.accountId]: state }, model: undefined, now }).kind === 'deferred';
|
|
85
|
+
if (!held && (candidate.kind !== 'selected' || candidate.provenance !== 'unknown'))
|
|
86
|
+
return;
|
|
87
|
+
// A recent measurement already refreshed the picture. Durable claims
|
|
88
|
+
// also rate-limit failed attempts and concurrent callers across restarts.
|
|
89
|
+
const slow = held && !(candidate.kind === 'selected' && candidate.provenance === 'unknown');
|
|
90
|
+
const interval = slow ? HELD_PROBE_INTERVAL_MS : LOCAL_PROBE_MIN_INTERVAL_MS;
|
|
91
|
+
const observedAt = Math.max(0, ...blockingUsageWindows(account, state, undefined, now).map(w => w.observedAt ?? state.usage?.observedAt ?? 0));
|
|
92
|
+
if (slow && now >= observedAt && now - observedAt < interval)
|
|
93
|
+
return;
|
|
94
|
+
if (isSubscriptionAccountRunning(account))
|
|
95
|
+
return;
|
|
96
|
+
if (localProbesInFlight.has(key)) {
|
|
97
|
+
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=skipped reason=claimed`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (options.isAtCapacity() || localProbesInFlight.size >= LOCAL_PROBE_MAX_CONCURRENT)
|
|
101
|
+
return;
|
|
102
|
+
// Reserve before asynchronous claiming, so concurrent preparePool calls
|
|
103
|
+
// cannot exceed the process-wide limit while claims are being written.
|
|
104
|
+
probe = { account, controller: new AbortController() };
|
|
105
|
+
localProbesInFlight.set(key, probe);
|
|
106
|
+
const claimed = await store.claimLocalProbe(account, now, interval);
|
|
107
|
+
if (probe.controller.signal.aborted) {
|
|
108
|
+
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=cancelled reason=account selected`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!claimed) {
|
|
112
|
+
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=skipped reason=claimed`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Capacity may change while the durable claim is written.
|
|
116
|
+
if (options.isAtCapacity()) {
|
|
117
|
+
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=cli_at_capacity`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
await runUsageProbe(account, { ...options, model: resolved.model, timeoutMs: LOCAL_PROBE_TIMEOUT_MS }, {
|
|
121
|
+
channelId, signal: probe.controller.signal,
|
|
122
|
+
onMeasurementComplete: () => {
|
|
123
|
+
if (localProbesInFlight.get(key) === probe)
|
|
124
|
+
localProbesInFlight.delete(key);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
poolInfo({ info: options.log }, `local probe account=${poolLabel(account)} result=${probe?.controller.signal.aborted ? 'cancelled reason=account selected' : 'unknown'}`);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
// An aborted run must not delete a newer reservation for the same key.
|
|
133
|
+
if (probe && localProbesInFlight.get(key) === probe)
|
|
134
|
+
localProbesInFlight.delete(key);
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
114
139
|
}
|
|
115
140
|
export async function preparePool(msg, resolved, pin, fetched, log, probeOptions) {
|
|
116
141
|
const pool = fetched ?? await fetchRuntimePool(msg);
|
|
@@ -121,18 +146,21 @@ export async function preparePool(msg, resolved, pin, fetched, log, probeOptions
|
|
|
121
146
|
// A pin is a spawn-boundary preference. Reactive same-session switches keep
|
|
122
147
|
// their existing strict ranking and do not receive this tolerance.
|
|
123
148
|
const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log, currentAccountId, currentAccountTolerance: PIN_HEADROOM_SWITCH_RELATIVE });
|
|
149
|
+
if (pool.kind === 'ready')
|
|
150
|
+
probeUnknownAccounts(pool.accounts, selected.kind === 'selected' ? selected.account.accountId : undefined, resolved, store, {
|
|
151
|
+
cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent,
|
|
152
|
+
log: line => (log ?? logger).info(line), ...probeOptions, store,
|
|
153
|
+
}, msg.teamvibe.channelId);
|
|
124
154
|
if (selected.kind === 'selected') {
|
|
125
155
|
abortLocalProbe(selected.account);
|
|
126
|
-
if (
|
|
156
|
+
if (pool.kind === 'ready' && currentAccountId && currentAccountId !== selected.account.accountId) {
|
|
127
157
|
const previous = pool.accounts.find(a => a.accountId === currentAccountId);
|
|
128
158
|
const previousSelection = previous ? await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: { kind: 'ready', accounts: [previous] }, store, now: Date.now() }) : undefined;
|
|
129
|
-
|
|
159
|
+
if (previous)
|
|
160
|
+
selected.switch = { fromAccountId: currentAccountId, reason: previousSelection?.kind === 'selected' ? 'better headroom' : 'pinned account unavailable' };
|
|
161
|
+
if (log)
|
|
162
|
+
poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=${previousSelection?.kind === 'selected' ? 'better headroom' : 'pinned account unavailable'}`);
|
|
130
163
|
}
|
|
131
|
-
if (pool.kind === 'ready')
|
|
132
|
-
probeUnknownAccounts(pool.accounts, selected.account.accountId, resolved, store, {
|
|
133
|
-
cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent,
|
|
134
|
-
log: line => (log ?? logger).info(line), ...probeOptions, store,
|
|
135
|
-
}, msg.teamvibe.channelId);
|
|
136
164
|
return { account: selected.account, store, pool, selection: selected };
|
|
137
165
|
}
|
|
138
166
|
if (selected.kind === 'no_accounts_configured')
|
|
@@ -168,16 +196,12 @@ async function poolUsageNotice(pool, resolved, store) {
|
|
|
168
196
|
const usage = state.usage;
|
|
169
197
|
const windows = usage && usage.observedAt <= now
|
|
170
198
|
? usage.windows.filter(w => now < usageWindowExpiresAt(usage, w)) : [];
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
model: resolved.model, now,
|
|
175
|
-
});
|
|
176
|
-
const rejected = rejection.kind === 'deferred' || (state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false);
|
|
177
|
-
const blockers = windows.filter(w => w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT));
|
|
199
|
+
const binding = blockingUsageWindows(account, state, resolved.model, now);
|
|
200
|
+
const rejected = binding.some(w => w.status === 'rejected');
|
|
201
|
+
const blockers = binding.filter(w => w.status !== 'rejected' && w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT));
|
|
178
202
|
const resets = blockers.map(w => w.resetAt);
|
|
179
203
|
return {
|
|
180
|
-
label: account.label, useFullQuota: account.useFullQuota, windows, rejected,
|
|
204
|
+
label: account.label, useFullQuota: account.useFullQuota, windows: windows.filter(w => binding.some(b => b.window === w.window && b.scope === w.scope)), rejected,
|
|
181
205
|
thresholdBlocked: blockers.length > 0,
|
|
182
206
|
resetAt: resets.length && resets.every((r) => r !== undefined) ? Math.max(...resets) : undefined,
|
|
183
207
|
};
|
|
@@ -40,6 +40,7 @@ export interface UsageWindow {
|
|
|
40
40
|
scope: 'global' | 'sonnet' | 'opus' | 'haiku' | 'fable' | 'uncertain';
|
|
41
41
|
/** CLI kind, or the server compatibility provenance for a legacy Fable allowance. */
|
|
42
42
|
kind?: 'seven_day_overage_included' | 'legacy-uncertain-seven-day';
|
|
43
|
+
pollerVersion?: string;
|
|
43
44
|
status?: 'allowed' | 'allowed_warning' | 'rejected';
|
|
44
45
|
/** Retained rejections keep their own clock when merged into newer measurements. */
|
|
45
46
|
observedAt?: number;
|
|
@@ -48,14 +49,24 @@ export interface UsageWindow {
|
|
|
48
49
|
resetAt?: number;
|
|
49
50
|
}
|
|
50
51
|
export interface UsageObservation {
|
|
52
|
+
pollerVersion?: string;
|
|
51
53
|
source: 'passive' | 'probe';
|
|
52
54
|
observedAt: number;
|
|
53
55
|
windows: readonly UsageWindow[];
|
|
54
56
|
}
|
|
55
57
|
/** Passive and probe percentages remain lower bounds until each window resets. */
|
|
56
58
|
export declare function usageWindowExpiresAt(usage: UsageObservation, window: UsageWindow): number;
|
|
57
|
-
/**
|
|
59
|
+
/** Release 0.8.4 preserves the raw kind. Unknown versions use the legacy path. */
|
|
60
|
+
export declare function isLegacyReporter(version: string | undefined): boolean;
|
|
61
|
+
/** Compatibility preserves reporter provenance even when projected windows are persisted.
|
|
62
|
+
* On Pro/Max the legacy distinct weekly allowance is Fable. Never infer a kind
|
|
63
|
+
* for modern reports, duplicate unknown windows, or an identical global copy.
|
|
64
|
+
*/
|
|
65
|
+
export declare function projectLegacyFableUsage<T extends UsageObservation | undefined>(usage: T, version?: string): T;
|
|
66
|
+
/** Latest measurements plus durable genuine rejections; omission or success never clears a genuine hold. */
|
|
58
67
|
export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
|
|
68
|
+
/** Preserve last-known meters, projecting legacy identity before its global companion is replaced. */
|
|
69
|
+
export declare function mergePersistedUsageObservations(previous: UsageObservation | undefined, incoming: UsageObservation | undefined, now: number): UsageObservation | undefined;
|
|
59
70
|
export interface ReactiveExhaustion {
|
|
60
71
|
observedAt: number;
|
|
61
72
|
quota: ClaudeQuotaFailure;
|
|
@@ -88,6 +99,7 @@ export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = ({
|
|
|
88
99
|
export declare function record(value: unknown): Record<string, unknown>;
|
|
89
100
|
export declare function normalizePassiveUsage(value: unknown, observedAt: number): UsageObservation;
|
|
90
101
|
export declare function normalizeProbeUsage(value: unknown, observedAt: number): UsageObservation;
|
|
102
|
+
export declare function family(model: string | undefined): 'sonnet' | 'opus' | 'haiku' | 'fable' | undefined;
|
|
91
103
|
export declare function reactiveEligibleAt(reactive: ReactiveExhaustion): number;
|
|
92
104
|
/** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
|
|
93
105
|
export declare function selectSubscriptionAccount<A extends AccountIdentity>(input: {
|
|
@@ -100,3 +112,7 @@ export declare function selectSubscriptionAccount<A extends AccountIdentity>(inp
|
|
|
100
112
|
currentAccountTolerance?: number;
|
|
101
113
|
now: number;
|
|
102
114
|
}): SelectionOutcome<A>;
|
|
115
|
+
/** Explain actual blockers with the same scope, clocks and thresholds as selection. */
|
|
116
|
+
export declare function blockingUsageWindows(account: AccountIdentity, state: AccountUsageState | undefined, model: string | undefined, now: number): (UsageWindow & {
|
|
117
|
+
eligibleAt: number;
|
|
118
|
+
})[];
|
|
@@ -10,23 +10,97 @@ export const POOL_RECHECK_MS = 15 * 60_000;
|
|
|
10
10
|
export const REJECTED_BLOCK_MS = 15 * 60_000;
|
|
11
11
|
/** Passive and probe percentages remain lower bounds until each window resets. */
|
|
12
12
|
export function usageWindowExpiresAt(usage, window) {
|
|
13
|
-
const ttlExpiry = usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS);
|
|
13
|
+
const ttlExpiry = (window.observedAt ?? usage.observedAt) + ((window.source ?? usage.source) === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS);
|
|
14
14
|
return window.resetAt ?? ttlExpiry;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
function isLatestMeasurement(usage, window) {
|
|
17
|
+
return (window.observedAt ?? usage.observedAt) === usage.observedAt &&
|
|
18
|
+
(window.source ?? usage.source) === usage.source;
|
|
19
|
+
}
|
|
20
|
+
/** An omitted model allowance remains exhausted until measured again or reset. */
|
|
21
|
+
function isScopedExhaustion(window) {
|
|
22
|
+
return window.scope !== 'global' && window.scope !== 'uncertain' &&
|
|
23
|
+
window.percent !== undefined && window.percent >= USAGE_THRESHOLD_PERCENT;
|
|
24
|
+
}
|
|
25
|
+
/** Release 0.8.4 preserves the raw kind. Unknown versions use the legacy path. */
|
|
26
|
+
export function isLegacyReporter(version) {
|
|
27
|
+
const match = version?.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
|
|
28
|
+
if (!match)
|
|
29
|
+
return true;
|
|
30
|
+
if (match[4]?.split('.').some(part => /^0\d+$/.test(part)))
|
|
31
|
+
return true;
|
|
32
|
+
const [major, minor, patch] = match.slice(1, 4).map(BigInt);
|
|
33
|
+
return major === 0n && (minor < 8n || (minor === 8n && (patch < 4n || (patch === 4n && !!match[4]))));
|
|
34
|
+
}
|
|
35
|
+
/** Compatibility preserves reporter provenance even when projected windows are persisted.
|
|
36
|
+
* On Pro/Max the legacy distinct weekly allowance is Fable. Never infer a kind
|
|
37
|
+
* for modern reports, duplicate unknown windows, or an identical global copy.
|
|
38
|
+
*/
|
|
39
|
+
export function projectLegacyFableUsage(usage, version) {
|
|
40
|
+
if (!usage)
|
|
41
|
+
return usage;
|
|
42
|
+
const sameObservation = (a, b) => (a.observedAt ?? usage.observedAt) === (b.observedAt ?? usage.observedAt) &&
|
|
43
|
+
(a.source ?? usage.source) === (b.source ?? usage.source);
|
|
44
|
+
const uncertainWeekly = (w) => w.scope === 'uncertain' && w.window === 'seven_day';
|
|
45
|
+
return {
|
|
46
|
+
...usage,
|
|
47
|
+
windows: usage.windows.map(w => {
|
|
48
|
+
if (!uncertainWeekly(w) || w.kind || !isLegacyReporter(w.pollerVersion ?? version ?? usage.pollerVersion))
|
|
49
|
+
return w;
|
|
50
|
+
if (usage.windows.filter(other => uncertainWeekly(other) && sameObservation(w, other)).length !== 1)
|
|
51
|
+
return w;
|
|
52
|
+
const global = usage.windows.find(other => other.scope === 'global' && other.window === 'seven_day' && sameObservation(w, other));
|
|
53
|
+
const distinct = global && ((w.percent !== undefined && w.percent !== global.percent) ||
|
|
54
|
+
(w.resetAt !== undefined && w.resetAt !== global.resetAt) ||
|
|
55
|
+
(w.status !== undefined && w.status !== global.status));
|
|
56
|
+
return distinct
|
|
57
|
+
? { ...w, scope: 'fable', kind: 'legacy-uncertain-seven-day' }
|
|
58
|
+
: w;
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Genuine Fable data supersedes older compatibility projections, using each window's clock. */
|
|
63
|
+
function supersedeLegacyFableProjections(observations, now) {
|
|
64
|
+
const projected = observations.map(usage => projectLegacyFableUsage(usage));
|
|
65
|
+
let newestGenuine = -Infinity;
|
|
66
|
+
for (const usage of projected) {
|
|
67
|
+
if (!usage || usage.observedAt > now)
|
|
68
|
+
continue;
|
|
69
|
+
for (const w of usage.windows) {
|
|
70
|
+
const observedAt = w.observedAt ?? usage.observedAt;
|
|
71
|
+
if (w.window === 'seven_day' && w.scope === 'fable' &&
|
|
72
|
+
w.kind !== 'legacy-uncertain-seven-day' && observedAt <= now)
|
|
73
|
+
newestGenuine = Math.max(newestGenuine, observedAt);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return projected.map(usage => usage && ({
|
|
77
|
+
...usage,
|
|
78
|
+
windows: usage.windows.filter(w => !(w.window === 'seven_day' && w.scope === 'fable' &&
|
|
79
|
+
w.kind === 'legacy-uncertain-seven-day' && (w.observedAt ?? usage.observedAt) < newestGenuine)),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
/** Latest measurements plus durable genuine rejections; omission or success never clears a genuine hold. */
|
|
17
83
|
export function mergeUsageObservations(observations, now) {
|
|
18
84
|
let latest;
|
|
19
85
|
const rejected = new Map();
|
|
20
|
-
|
|
86
|
+
const scopedExhausted = new Map();
|
|
87
|
+
for (const usage of supersedeLegacyFableProjections(observations, now)) {
|
|
21
88
|
if (!usage || usage.observedAt > now)
|
|
22
89
|
continue;
|
|
23
90
|
if (!latest || usage.observedAt >= latest.observedAt)
|
|
24
91
|
latest = usage;
|
|
25
92
|
for (const w of usage.windows) {
|
|
26
93
|
const observedAt = w.observedAt ?? usage.observedAt;
|
|
27
|
-
if (
|
|
94
|
+
if (observedAt > now)
|
|
28
95
|
continue;
|
|
29
96
|
const key = `${w.window}:${w.scope}`;
|
|
97
|
+
if (w.scope !== 'global' && w.scope !== 'uncertain' && w.status !== 'rejected') {
|
|
98
|
+
const previous = scopedExhausted.get(key);
|
|
99
|
+
if (!previous || observedAt >= previous.observedAt)
|
|
100
|
+
scopedExhausted.set(key, { ...w, observedAt, source: w.source ?? usage.source });
|
|
101
|
+
}
|
|
102
|
+
if (w.status !== 'rejected')
|
|
103
|
+
continue;
|
|
30
104
|
const previous = rejected.get(key);
|
|
31
105
|
if (!previous || observedAt >= previous.observedAt)
|
|
32
106
|
rejected.set(key, { ...w, observedAt, source: w.source ?? usage.source });
|
|
@@ -35,16 +109,64 @@ export function mergeUsageObservations(observations, now) {
|
|
|
35
109
|
if (!latest)
|
|
36
110
|
return undefined;
|
|
37
111
|
const active = [...rejected.values()].filter(w => now < (w.resetAt ?? w.observedAt + REJECTED_BLOCK_MS));
|
|
112
|
+
const retained = [...scopedExhausted.values()].filter(w => isScopedExhaustion(w) &&
|
|
113
|
+
!latest.windows.some(other => other.window === w.window && other.scope === w.scope) &&
|
|
114
|
+
!active.some(other => other.window === w.window && other.scope === w.scope) &&
|
|
115
|
+
now < usageWindowExpiresAt(latest, w));
|
|
38
116
|
return {
|
|
39
117
|
...latest,
|
|
40
118
|
windows: [
|
|
41
119
|
...latest.windows.filter(w => w.status !== 'rejected' &&
|
|
42
|
-
(w.observedAt ?? latest.observedAt)
|
|
43
|
-
(w
|
|
120
|
+
(w.observedAt ?? latest.observedAt) <= now &&
|
|
121
|
+
(isLatestMeasurement(latest, w) || (isScopedExhaustion(w) && now < usageWindowExpiresAt(latest, w))))
|
|
122
|
+
.slice(0, Math.max(0, 133 - active.length - retained.length)),
|
|
123
|
+
...retained,
|
|
44
124
|
...active,
|
|
45
125
|
],
|
|
46
126
|
};
|
|
47
127
|
}
|
|
128
|
+
/** Preserve last-known meters, projecting legacy identity before its global companion is replaced. */
|
|
129
|
+
export function mergePersistedUsageObservations(previous, incoming, now) {
|
|
130
|
+
// Apply the same replacement before retaining last-known meters for storage.
|
|
131
|
+
[previous, incoming] = supersedeLegacyFableProjections([previous, incoming], now);
|
|
132
|
+
incoming ??= previous;
|
|
133
|
+
if (!incoming)
|
|
134
|
+
return undefined;
|
|
135
|
+
const merged = mergeUsageObservations([previous, incoming], now);
|
|
136
|
+
const rejected = merged?.windows.filter(w => w.status === 'rejected') ?? [];
|
|
137
|
+
const windows = (previous?.windows ?? []).filter(w => w.status !== 'rejected').map((w) => ({
|
|
138
|
+
...w,
|
|
139
|
+
observedAt: w.observedAt ?? previous.observedAt,
|
|
140
|
+
source: w.source ?? previous.source,
|
|
141
|
+
}));
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
for (const window of incoming.windows.filter(w => w.status !== 'rejected')) {
|
|
144
|
+
const group = `${window.window}:${window.scope}`;
|
|
145
|
+
const values = groups.get(group) ?? [];
|
|
146
|
+
values.push({
|
|
147
|
+
...window,
|
|
148
|
+
observedAt: window.observedAt ?? incoming.observedAt,
|
|
149
|
+
source: window.source ?? incoming.source,
|
|
150
|
+
});
|
|
151
|
+
groups.set(group, values);
|
|
152
|
+
}
|
|
153
|
+
for (const [group, values] of groups) {
|
|
154
|
+
const matches = (w) => `${w.window}:${w.scope}` === group;
|
|
155
|
+
const old = windows.filter(matches);
|
|
156
|
+
const newest = Math.max(...values.map((w) => w.observedAt));
|
|
157
|
+
// A failed/incomplete observation must not erase a last-known measurement.
|
|
158
|
+
if (old.length &&
|
|
159
|
+
(newest <= Math.max(...old.map((w) => w.observedAt)) ||
|
|
160
|
+
values.every((w) => w.percent === undefined)))
|
|
161
|
+
continue;
|
|
162
|
+
for (let i = windows.length - 1; i >= 0; i--)
|
|
163
|
+
if (matches(windows[i]))
|
|
164
|
+
windows.splice(i, 1);
|
|
165
|
+
windows.push(...values);
|
|
166
|
+
}
|
|
167
|
+
const latest = previous && previous.observedAt > incoming.observedAt ? previous : incoming;
|
|
168
|
+
return { ...latest, windows: [...windows.slice(0, 133 - rejected.length), ...rejected] };
|
|
169
|
+
}
|
|
48
170
|
export function record(value) {
|
|
49
171
|
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
50
172
|
}
|
|
@@ -143,7 +265,7 @@ export function normalizeProbeUsage(value, observedAt) {
|
|
|
143
265
|
}
|
|
144
266
|
return { source: 'probe', observedAt, windows };
|
|
145
267
|
}
|
|
146
|
-
function family(model) {
|
|
268
|
+
export function family(model) {
|
|
147
269
|
return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku|fable)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
|
|
148
270
|
}
|
|
149
271
|
function applicable(scope, model) {
|
|
@@ -194,7 +316,7 @@ export function selectSubscriptionAccount(input) {
|
|
|
194
316
|
if (input.now < until)
|
|
195
317
|
blockers.push(until);
|
|
196
318
|
}
|
|
197
|
-
const usage = state?.usage;
|
|
319
|
+
const usage = projectLegacyFableUsage(state?.usage);
|
|
198
320
|
// A provider rejection outlives snapshot freshness. Never score it as headroom.
|
|
199
321
|
if (usage && usage.observedAt <= input.now) {
|
|
200
322
|
for (const w of usage.windows) {
|
|
@@ -208,8 +330,13 @@ export function selectSubscriptionAccount(input) {
|
|
|
208
330
|
blockers.push(until);
|
|
209
331
|
}
|
|
210
332
|
}
|
|
211
|
-
const windows = usage && usage.observedAt <= input.now
|
|
212
|
-
|
|
333
|
+
const windows = usage && usage.observedAt <= input.now
|
|
334
|
+
? usage.windows.filter(w => w.status !== 'rejected' &&
|
|
335
|
+
(w.observedAt ?? usage.observedAt) <= input.now &&
|
|
336
|
+
(isLatestMeasurement(usage, w) || isScopedExhaustion(w)) &&
|
|
337
|
+
applicable(w.scope, input.model) && input.now < usageWindowExpiresAt(usage, w))
|
|
338
|
+
: [];
|
|
339
|
+
const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && input.model !== undefined && !family(input.model)));
|
|
213
340
|
for (const w of windows) {
|
|
214
341
|
if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
|
|
215
342
|
blockers.push(usageWindowExpiresAt(usage, w));
|
|
@@ -241,3 +368,20 @@ export function selectSubscriptionAccount(input) {
|
|
|
241
368
|
return { kind: 'selected', provenance: 'unknown', ...unknown[0] };
|
|
242
369
|
return { kind: 'deferred', eligibleAt: Math.min(...deadlines) };
|
|
243
370
|
}
|
|
371
|
+
/** Explain actual blockers with the same scope, clocks and thresholds as selection. */
|
|
372
|
+
export function blockingUsageWindows(account, state, model, now) {
|
|
373
|
+
const pool = { kind: 'ready', accounts: [account] };
|
|
374
|
+
const usage = projectLegacyFableUsage(state?.usage);
|
|
375
|
+
const windows = [];
|
|
376
|
+
for (const window of usage?.windows ?? []) {
|
|
377
|
+
const result = selectSubscriptionAccount({ pool, states: { [account.accountId]: { usage: { ...usage, windows: [window] } } }, model, now });
|
|
378
|
+
if (result.kind === 'deferred')
|
|
379
|
+
windows.push({ ...window, eligibleAt: result.eligibleAt });
|
|
380
|
+
}
|
|
381
|
+
for (const reactive of state?.reactive ?? []) {
|
|
382
|
+
const result = selectSubscriptionAccount({ pool, states: { [account.accountId]: { reactive: [reactive] } }, model, now });
|
|
383
|
+
if (result.kind === 'deferred')
|
|
384
|
+
windows.push({ observedAt: reactive.observedAt, window: reactive.quota.window ?? 'seven_day', scope: reactive.quota.modelScope ? family(reactive.quota.modelScope) ?? 'uncertain' : 'global', status: 'rejected', resetAt: reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : undefined, eligibleAt: result.eligibleAt });
|
|
385
|
+
}
|
|
386
|
+
return windows;
|
|
387
|
+
}
|
|
@@ -9,6 +9,8 @@ export interface ProbeObservation {
|
|
|
9
9
|
usage?: UsageObservation;
|
|
10
10
|
}
|
|
11
11
|
export interface StoredAccountUsage {
|
|
12
|
+
/** Durable last attempt, including unsuccessful and cancelled local probes. */
|
|
13
|
+
localProbeClaimedAt?: number;
|
|
12
14
|
/** Latest measured snapshot plus active rejected windows, persisted across observations. */
|
|
13
15
|
usage?: UsageObservation;
|
|
14
16
|
passive?: {
|
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
|
|
6
|
-
import { normalizePassiveUsage, mergeUsageObservations, record, reactiveEligibleAt } from './subscription-selection.js';
|
|
6
|
+
import { normalizePassiveUsage, mergeUsageObservations, record, reactiveEligibleAt, family } from './subscription-selection.js';
|
|
7
7
|
export function usageStateForSelection(state, now) {
|
|
8
8
|
return {
|
|
9
9
|
usage: mergeUsageObservations([
|
|
@@ -55,7 +55,7 @@ export function safeRateLimitInfo(info) {
|
|
|
55
55
|
return parsed;
|
|
56
56
|
}
|
|
57
57
|
export function safeQuota(quota) {
|
|
58
|
-
const scope = typeof quota.modelScope === 'string' ? quota.modelScope
|
|
58
|
+
const scope = typeof quota.modelScope === 'string' ? family(quota.modelScope) : undefined;
|
|
59
59
|
const reset = typeof quota.resetAt === 'string' ? Date.parse(quota.resetAt) : NaN;
|
|
60
60
|
return {
|
|
61
61
|
reason: ['usage_limit', 'weekly_limit', 'session_limit', 'extra_usage'].includes(quota.reason) ? quota.reason : 'usage_limit',
|
|
@@ -80,7 +80,7 @@ function safeUsage(value, source) {
|
|
|
80
80
|
if (observedAt !== undefined)
|
|
81
81
|
validTime(observedAt);
|
|
82
82
|
const source = w['source'] === 'passive' || w['source'] === 'probe' ? w['source'] : undefined;
|
|
83
|
-
return { window, scope, ...(w['kind'] === 'seven_day_overage_included' ? { kind: w['kind'] } : {}), percent, resetAt, ...(status ? { status } : {}), ...(observedAt ? { observedAt } : {}), ...(source ? { source } : {}) };
|
|
83
|
+
return { window, scope, ...((w['kind'] === 'seven_day_overage_included' || w['kind'] === 'legacy-uncertain-seven-day') ? { kind: w['kind'] } : {}), percent, resetAt, ...(status ? { status } : {}), ...(observedAt ? { observedAt } : {}), ...(source ? { source } : {}) };
|
|
84
84
|
});
|
|
85
85
|
if (usage['windows'].length > 133)
|
|
86
86
|
windows.push({ window: 'seven_day', scope: 'uncertain' });
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.8.6-rc.
|
|
3
|
+
"version": "0.8.6-rc.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@teamvibe/poller",
|
|
9
|
-
"version": "0.8.6-rc.
|
|
9
|
+
"version": "0.8.6-rc.4",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@aws-sdk/client-dynamodb": "3.1108.0",
|
|
12
12
|
"@aws-sdk/client-sqs": "3.1108.0",
|