@teamvibe/poller 0.8.7-rc.1 → 0.8.8

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.
@@ -10,6 +10,13 @@ export declare const PROBE_USAGE_TTL_MS = 60000;
10
10
  export declare const POOL_RECHECK_MS: number;
11
11
  /** Safety hold from the rejection observation when the provider gives no reset. */
12
12
  export declare const REJECTED_BLOCK_MS: number;
13
+ export declare const VERIFICATION_MAX_BACKOFF_MS: number;
14
+ export interface VerificationEvidence {
15
+ observedAt: number;
16
+ source: 'passive' | 'probe';
17
+ status: 'allowed' | 'allowed_warning';
18
+ percent: number;
19
+ }
13
20
  export interface AccountIdentity {
14
21
  workspaceId: string;
15
22
  accountId: string;
@@ -47,6 +54,11 @@ export interface UsageWindow {
47
54
  source?: 'passive' | 'probe';
48
55
  percent?: number;
49
56
  resetAt?: number;
57
+ /** Missing on legacy rejections means the first consecutive rejection. */
58
+ rejectionCount?: number;
59
+ lastVerificationAttemptAt?: number;
60
+ /** A genuine same-window measurement; never inferred from an omitted meter. */
61
+ verificationEvidence?: VerificationEvidence;
50
62
  }
51
63
  export interface UsageObservation {
52
64
  pollerVersion?: string;
@@ -67,11 +79,11 @@ export declare function projectLegacyFableUsage<T extends UsageObservation | und
67
79
  * Deduplicate uncertain windows before splitting measurements from rejections.
68
80
  */
69
81
  export declare function supersedeLegacyFableProjections(observations: readonly (UsageObservation | undefined)[], now: number): (UsageObservation | undefined)[];
70
- /** Latest measurements plus durable genuine rejections; omission or success never clears a genuine hold. */
82
+ /** Latest measurements plus durable rejections; only a claimed real spawn can clear a hold. */
71
83
  export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
72
84
  /** Preserve last-known meters, projecting legacy identity before its global companion is replaced. */
73
85
  export declare function mergePersistedUsageObservations(previous: UsageObservation | undefined, incoming: UsageObservation | undefined, now: number): UsageObservation | undefined;
74
- export interface ReactiveExhaustion {
86
+ export interface ReactiveExhaustion extends Pick<UsageWindow, 'rejectionCount' | 'lastVerificationAttemptAt' | 'verificationEvidence'> {
75
87
  observedAt: number;
76
88
  quota: ClaudeQuotaFailure;
77
89
  }
@@ -106,6 +118,13 @@ export declare function record(value: unknown): Record<string, unknown>;
106
118
  export declare function normalizePassiveUsage(value: unknown, observedAt: number): UsageObservation;
107
119
  export declare function normalizeProbeUsage(value: unknown, observedAt: number): UsageObservation;
108
120
  export declare function family(model: string | undefined): 'sonnet' | 'opus' | 'haiku' | 'fable' | undefined;
121
+ export declare function rejectionResetAt(window: UsageWindow, observedAt: number): number;
122
+ export declare function verificationEligibleAt(window: UsageWindow, observedAt: number): number;
123
+ export declare function rejectionEvidence(usage: UsageObservation | undefined, window: UsageWindow, now: number): VerificationEvidence | undefined;
124
+ export declare function rejectionEligibleAt(usage: UsageObservation | undefined, window: UsageWindow, account: AccountIdentity, now: number): number;
125
+ export declare function reactiveUsageWindow(reactive: ReactiveExhaustion): UsageWindow;
126
+ /** Due windows are claimed durably at the real spawn boundary, never by pure selection. */
127
+ export declare function verificationWindows(account: AccountIdentity, state: AccountUsageState, model: string | undefined, now: number): UsageWindow[];
109
128
  export declare function reactiveEligibleAt(reactive: ReactiveExhaustion): number;
110
129
  /** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
111
130
  export declare function selectSubscriptionAccount<A extends AccountIdentity>(input: {
@@ -8,6 +8,7 @@ export const PROBE_USAGE_TTL_MS = 60_000;
8
8
  export const POOL_RECHECK_MS = 15 * 60_000;
9
9
  /** Safety hold from the rejection observation when the provider gives no reset. */
10
10
  export const REJECTED_BLOCK_MS = 15 * 60_000;
11
+ export const VERIFICATION_MAX_BACKOFF_MS = 8 * 60 * 60_000;
11
12
  /** Passive and probe percentages remain lower bounds until each window resets. */
12
13
  export function usageWindowExpiresAt(usage, window) {
13
14
  const ttlExpiry = (window.observedAt ?? usage.observedAt) + ((window.source ?? usage.source) === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS);
@@ -116,7 +117,7 @@ export function supersedeLegacyFableProjections(observations, now) {
116
117
  }),
117
118
  }));
118
119
  }
119
- /** Latest measurements plus durable genuine rejections; omission or success never clears a genuine hold. */
120
+ /** Latest measurements plus durable rejections; only a claimed real spawn can clear a hold. */
120
121
  export function mergeUsageObservations(observations, now) {
121
122
  let latest;
122
123
  const rejected = new Map();
@@ -139,13 +140,24 @@ export function mergeUsageObservations(observations, now) {
139
140
  if (w.status !== 'rejected')
140
141
  continue;
141
142
  const previous = rejected.get(key);
142
- if (!previous || observedAt >= previous.observedAt)
143
- rejected.set(key, { ...w, observedAt, source: w.source ?? usage.source });
143
+ if (!previous || observedAt >= previous.observedAt) {
144
+ const consecutive = previous && observedAt > previous.observedAt && observedAt < rejectionResetAt(previous, previous.observedAt);
145
+ rejected.set(key, {
146
+ ...(previous?.observedAt === observedAt ? previous : {}),
147
+ ...w, observedAt, source: w.source ?? usage.source,
148
+ ...(consecutive && w.resetAt === undefined && previous.resetAt !== undefined ? { resetAt: previous.resetAt } : {}),
149
+ ...(consecutive || w.rejectionCount !== undefined || previous?.observedAt === observedAt && previous.rejectionCount !== undefined
150
+ ? { rejectionCount: Math.max(w.rejectionCount ?? 1, consecutive ? (previous.rejectionCount ?? 1) + 1 : previous?.rejectionCount ?? 1) } : {}),
151
+ });
152
+ }
144
153
  }
145
154
  }
146
155
  if (!latest)
147
156
  return undefined;
148
- const active = [...rejected.values()].filter(w => now < (w.resetAt ?? w.observedAt + REJECTED_BLOCK_MS));
157
+ const active = [...rejected.values()].filter(w => now < rejectionResetAt(w, w.observedAt)).map(w => {
158
+ const evidence = latestVerificationEvidence(observations, w, w.observedAt, now);
159
+ return evidence ? { ...w, verificationEvidence: evidence } : w;
160
+ });
149
161
  const retained = [...scopedExhausted.values()].filter(w => isScopedExhaustion(w) &&
150
162
  !latest.windows.some(other => other.window === w.window && other.scope === w.scope) &&
151
163
  !active.some(other => other.window === w.window && other.scope === w.scope) &&
@@ -281,7 +293,9 @@ export function normalizeProbeUsage(value, observedAt) {
281
293
  if (raw == null)
282
294
  continue;
283
295
  const item = record(raw);
284
- windows.push({ window, scope, percent: percent(item['utilization'], 100), resetAt: date(item['resets_at']) });
296
+ windows.push({ window, scope, percent: percent(item['utilization'], 100), resetAt: date(item['resets_at']),
297
+ ...(percent(item['utilization'], 100) !== undefined ? { status: 'allowed' } : {}),
298
+ });
285
299
  }
286
300
  // The optional endpoint is undocumented. Accept only the explicit window /
287
301
  // model form below; arbitrary internal codenames never map to a family.
@@ -295,7 +309,9 @@ export function normalizeProbeUsage(value, observedAt) {
295
309
  }
296
310
  const model = item['model'];
297
311
  const scope = model === 'global' ? 'global' : typeof model === 'string' ? family(model) ?? 'uncertain' : 'uncertain';
298
- windows.push({ window, scope, percent: percent(item['utilization'], 100), resetAt: date(item['resets_at']) });
312
+ windows.push({ window, scope, percent: percent(item['utilization'], 100), resetAt: date(item['resets_at']),
313
+ ...(percent(item['utilization'], 100) !== undefined ? { status: 'allowed' } : {}),
314
+ });
299
315
  }
300
316
  if (info['limits'].length > 128)
301
317
  windows.push({ window: 'seven_day', scope: 'uncertain' });
@@ -308,6 +324,63 @@ export function family(model) {
308
324
  function applicable(scope, model) {
309
325
  return scope === 'global' || scope === 'uncertain' || family(model) === undefined || scope === family(model);
310
326
  }
327
+ export function rejectionResetAt(window, observedAt) {
328
+ return window.resetAt ?? observedAt + REJECTED_BLOCK_MS;
329
+ }
330
+ export function verificationEligibleAt(window, observedAt) {
331
+ return Math.min(rejectionResetAt(window, observedAt), observedAt +
332
+ Math.min(VERIFICATION_MAX_BACKOFF_MS, REJECTED_BLOCK_MS * 2 ** Math.min(5, Math.max(0, (window.rejectionCount ?? 1) - 1))));
333
+ }
334
+ function latestVerificationEvidence(observations, rejected, rejectedAt, now) {
335
+ let evidence = rejected.verificationEvidence;
336
+ if (evidence && (evidence.observedAt <= rejectedAt || evidence.observedAt > now))
337
+ evidence = undefined;
338
+ // Uncertain/projected identity cannot prove that the provider's exact quota recovered.
339
+ if (rejected.scope === 'uncertain' || rejected.kind === 'legacy-uncertain-seven-day')
340
+ return undefined;
341
+ for (const usage of observations) {
342
+ if (!usage || usage.observedAt > now)
343
+ continue;
344
+ for (const w of usage.windows) {
345
+ const observedAt = w.observedAt ?? usage.observedAt;
346
+ if (w.window !== rejected.window || w.scope !== rejected.scope || w.kind === 'legacy-uncertain-seven-day' ||
347
+ observedAt <= rejectedAt || observedAt > now || w.percent === undefined ||
348
+ (w.status !== 'allowed' && w.status !== 'allowed_warning'))
349
+ continue;
350
+ if (!evidence || observedAt > evidence.observedAt || observedAt === evidence.observedAt && w.percent > evidence.percent)
351
+ evidence = { observedAt, source: w.source ?? usage.source, status: w.status, percent: w.percent };
352
+ }
353
+ }
354
+ return evidence;
355
+ }
356
+ export function rejectionEvidence(usage, window, now) {
357
+ return latestVerificationEvidence([usage], window, window.observedAt ?? usage?.observedAt ?? now, now);
358
+ }
359
+ export function rejectionEligibleAt(usage, window, account, now) {
360
+ const observedAt = window.observedAt ?? usage?.observedAt ?? now;
361
+ const resetAt = rejectionResetAt(window, observedAt);
362
+ const evidence = rejectionEvidence(usage, window, now);
363
+ return evidence && evidence.percent < (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT) &&
364
+ !(window.lastVerificationAttemptAt !== undefined && window.lastVerificationAttemptAt >= observedAt)
365
+ ? verificationEligibleAt(window, observedAt) : resetAt;
366
+ }
367
+ export function reactiveUsageWindow(reactive) {
368
+ return {
369
+ window: reactive.quota.window ?? 'seven_day',
370
+ scope: reactive.quota.modelScope ? family(reactive.quota.modelScope) ?? 'uncertain' : 'global',
371
+ status: 'rejected', observedAt: reactive.observedAt, resetAt: reactiveEligibleAt(reactive),
372
+ rejectionCount: reactive.rejectionCount, lastVerificationAttemptAt: reactive.lastVerificationAttemptAt,
373
+ verificationEvidence: reactive.verificationEvidence,
374
+ };
375
+ }
376
+ /** Due windows are claimed durably at the real spawn boundary, never by pure selection. */
377
+ export function verificationWindows(account, state, model, now) {
378
+ return [...(state.usage?.windows ?? []), ...(state.reactive ?? []).filter(r => r.quota.window !== undefined).map(reactiveUsageWindow)].filter(w => {
379
+ const observedAt = w.observedAt ?? state.usage?.observedAt ?? now;
380
+ return w.status === 'rejected' && observedAt <= now && applicable(w.scope, model) &&
381
+ now < rejectionResetAt(w, observedAt) && now >= rejectionEligibleAt(state.usage, w, account, now);
382
+ });
383
+ }
311
384
  export function reactiveEligibleAt(reactive) {
312
385
  const parsed = reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : NaN;
313
386
  return Number.isFinite(parsed) && parsed > reactive.observedAt ? parsed : reactive.observedAt + POOL_RECHECK_MS;
@@ -342,6 +415,7 @@ export function selectSubscriptionAccount(input) {
342
415
  continue;
343
416
  const state = input.states[account.accountId];
344
417
  const blockers = [];
418
+ const usage = supersedeLegacyFableProjections([state?.usage], input.now)[0];
345
419
  for (const reactive of state?.reactive ?? []) {
346
420
  // Like durable pause createdAt, future observations expire on clock rollback.
347
421
  if (input.now < reactive.observedAt)
@@ -349,12 +423,11 @@ export function selectSubscriptionAccount(input) {
349
423
  const scope = reactive.quota.modelScope;
350
424
  if (scope && family(scope) && family(input.model) && family(scope) !== family(input.model))
351
425
  continue;
352
- const until = reactiveEligibleAt(reactive);
426
+ const until = reactive.quota.window ? rejectionEligibleAt(usage, reactiveUsageWindow(reactive), account, input.now) : reactiveEligibleAt(reactive);
353
427
  if (input.now < until)
354
428
  blockers.push(until);
355
429
  }
356
- const usage = supersedeLegacyFableProjections([state?.usage], input.now)[0];
357
- // A provider rejection outlives snapshot freshness. Never score it as headroom.
430
+ // A contradicted rejection permits one claimed verification after backoff.
358
431
  if (usage && usage.observedAt <= input.now) {
359
432
  for (const w of usage.windows) {
360
433
  if (w.status !== 'rejected' || !applicable(w.scope, input.model))
@@ -362,7 +435,7 @@ export function selectSubscriptionAccount(input) {
362
435
  const observedAt = w.observedAt ?? usage.observedAt;
363
436
  if (observedAt > input.now)
364
437
  continue;
365
- const until = w.resetAt ?? observedAt + REJECTED_BLOCK_MS;
438
+ const until = rejectionEligibleAt(usage, w, account, input.now);
366
439
  if (input.now < until)
367
440
  blockers.push(until);
368
441
  }
@@ -414,14 +487,18 @@ export function blockingUsageWindows(account, state, model, now) {
414
487
  const usage = supersedeLegacyFableProjections([state?.usage], now)[0];
415
488
  const windows = [];
416
489
  for (const window of usage?.windows ?? []) {
417
- const result = selectSubscriptionAccount({ pool, states: { [account.accountId]: { usage: { ...usage, windows: [window] } } }, model, now });
490
+ const evidence = window.status === 'rejected' ? rejectionEvidence(usage, window, now) : undefined;
491
+ const result = selectSubscriptionAccount({ pool, states: { [account.accountId]: { usage: { ...usage, windows: [{ ...window, ...(evidence ? { verificationEvidence: evidence } : {}) }] } } }, model, now });
418
492
  if (result.kind === 'deferred')
419
493
  windows.push({ ...window, eligibleAt: result.eligibleAt });
420
494
  }
421
495
  for (const reactive of state?.reactive ?? []) {
422
- const result = selectSubscriptionAccount({ pool, states: { [account.accountId]: { reactive: [reactive] } }, model, now });
423
- if (result.kind === 'deferred')
424
- 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 });
496
+ const window = reactiveUsageWindow(reactive);
497
+ if (applicable(window.scope, model) && reactive.observedAt <= now) {
498
+ const eligibleAt = reactive.quota.window ? rejectionEligibleAt(usage, window, account, now) : reactiveEligibleAt(reactive);
499
+ if (now < eligibleAt)
500
+ windows.push({ ...window, resetAt: reactive.quota.resetAt ? Date.parse(reactive.quota.resetAt) : undefined, eligibleAt });
501
+ }
425
502
  }
426
503
  return windows;
427
504
  }
@@ -1,6 +1,6 @@
1
1
  import { type ClaudeRateLimitInfo } from './claude-rate-limit.js';
2
2
  import type { ClaudeQuotaFailure } from './claude-quota.js';
3
- import { type AccountIdentity, type AccountUsageState, type ReactiveExhaustion, type UsageObservation } from './subscription-selection.js';
3
+ import { type AccountIdentity, type AccountUsageState, type ReactiveExhaustion, type UsageObservation, type UsageWindow } from './subscription-selection.js';
4
4
  export type UsageKey = Pick<AccountIdentity, 'workspaceId' | 'accountId' | 'credentialVersion'>;
5
5
  export type ProbeStatus = 'attempted' | 'ok' | 'unsupported' | 'unauthorized' | 'unavailable' | 'cancelled';
6
6
  export interface ProbeObservation {
@@ -34,6 +34,9 @@ export interface SubscriptionUsageStore {
34
34
  claimLocalProbe(key: UsageKey, observedAt: number, minIntervalMs: number): Promise<boolean>;
35
35
  claimProbeRequest(requestId: string, expiresAt: number): Promise<boolean>;
36
36
  finishProbe(key: UsageKey, observation: ProbeObservation): Promise<void>;
37
+ /** Atomic revalidation and one durable claim immediately before a real spawn. */
38
+ beginVerification(key: AccountIdentity, model: string | undefined, observedAt: number): Promise<UsageWindow[] | undefined>;
39
+ completeVerification(key: UsageKey, attemptAt: number, observedAt: number): Promise<void>;
37
40
  }
38
41
  export declare function usageStateForSelection(state: StoredAccountUsage, now: number): AccountUsageState;
39
42
  export declare class SubscriptionUsageStoreError extends Error {
@@ -54,4 +57,6 @@ export declare class FileSubscriptionUsageStore implements SubscriptionUsageStor
54
57
  /** Request claims live in the local data directory, alongside usage, never in a brain. */
55
58
  claimProbeRequest(requestId: string, expiresAt: number): Promise<boolean>;
56
59
  finishProbe(key: UsageKey, observation: ProbeObservation): Promise<void>;
60
+ beginVerification(key: AccountIdentity, model: string | undefined, observedAt: number): Promise<UsageWindow[] | undefined>;
61
+ completeVerification(key: UsageKey, attemptAt: number, observedAt: number): Promise<void>;
57
62
  }
@@ -1,9 +1,10 @@
1
1
  import { redactStartupText } from './redact-text.js';
2
+ import { logger } from './logger.js';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
- import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
5
  import { join, resolve } from 'node:path';
5
6
  import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
6
- import { normalizePassiveUsage, mergePersistedUsageObservations, mergeUsageObservations, record, reactiveEligibleAt, family } from './subscription-selection.js';
7
+ import { normalizePassiveUsage, mergePersistedUsageObservations, mergeUsageObservations, record, reactiveEligibleAt, reactiveUsageWindow, rejectionEvidence, rejectionResetAt, verificationEligibleAt, verificationWindows, selectSubscriptionAccount, family, USAGE_THRESHOLD_PERCENT } from './subscription-selection.js';
7
8
  export function usageStateForSelection(state, now) {
8
9
  return {
9
10
  usage: mergeUsageObservations([
@@ -80,12 +81,31 @@ function safeUsage(value, source) {
80
81
  if (observedAt !== undefined)
81
82
  validTime(observedAt);
82
83
  const source = w['source'] === 'passive' || w['source'] === 'probe' ? w['source'] : undefined;
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
+ 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 } : {}), ...safeVerificationFields(w) };
84
85
  });
85
86
  if (usage['windows'].length > 133)
86
87
  windows.push({ window: 'seven_day', scope: 'uncertain' });
87
88
  return { source, observedAt: usage['observedAt'], windows };
88
89
  }
90
+ function safeVerificationFields(value) {
91
+ const result = {};
92
+ const count = value['rejectionCount'];
93
+ if (typeof count === 'number' && Number.isSafeInteger(count) && count >= 1)
94
+ result.rejectionCount = count;
95
+ const attemptAt = value['lastVerificationAttemptAt'];
96
+ if (typeof attemptAt === 'number') {
97
+ validTime(attemptAt);
98
+ result.lastVerificationAttemptAt = attemptAt;
99
+ }
100
+ const evidence = record(value['verificationEvidence']);
101
+ if (typeof evidence['observedAt'] === 'number' && (evidence['source'] === 'probe' || evidence['source'] === 'passive') &&
102
+ (evidence['status'] === 'allowed' || evidence['status'] === 'allowed_warning') &&
103
+ typeof evidence['percent'] === 'number' && Number.isFinite(evidence['percent']) && evidence['percent'] >= 0 && evidence['percent'] <= 100) {
104
+ validTime(evidence['observedAt']);
105
+ result.verificationEvidence = { observedAt: evidence['observedAt'], source: evidence['source'], status: evidence['status'], percent: evidence['percent'] };
106
+ }
107
+ return result;
108
+ }
89
109
  function safeProbe(value) {
90
110
  const obj = record(value);
91
111
  const status = obj['status'];
@@ -126,7 +146,7 @@ function safeReactive(value) {
126
146
  if (typeof obj['observedAt'] !== 'number' || !obj['quota'] || typeof obj['quota'] !== 'object')
127
147
  throw new SubscriptionUsageStoreError();
128
148
  validTime(obj['observedAt']);
129
- return { observedAt: obj['observedAt'], quota: safeQuota(obj['quota']) };
149
+ return { observedAt: obj['observedAt'], quota: safeQuota(obj['quota']), ...safeVerificationFields(obj) };
130
150
  });
131
151
  }
132
152
  /** One file per version + one reactive file per workspace/account. No credentials or raw provider bodies. */
@@ -139,6 +159,84 @@ export class FileSubscriptionUsageStore {
139
159
  throw new SubscriptionUsageStoreError();
140
160
  return join(this.#directory, `${createHash('sha256').update(JSON.stringify(parts)).digest('hex')}.json`);
141
161
  }
162
+ #claimPath(key, window, fallbackAt) {
163
+ // The epoch identifies the backoff step, so processes with different wall
164
+ // clocks still compete for the same exclusive marker.
165
+ const epoch = verificationEligibleAt(window, window.observedAt ?? fallbackAt);
166
+ return join(this.#directory, 'claims', `${encodeURIComponent(key.accountId)}.${window.scope}.${window.window}.${epoch}.claim`);
167
+ }
168
+ async #claimMarkers(key) {
169
+ const directory = join(this.#directory, 'claims');
170
+ const claims = new Map();
171
+ try {
172
+ const names = await readdir(directory);
173
+ const now = Date.now();
174
+ for (const name of names) {
175
+ if (!name.startsWith(`${encodeURIComponent(key.accountId)}.`) || !name.endsWith('.claim'))
176
+ continue;
177
+ const path = join(directory, name);
178
+ try {
179
+ const raw = await readFile(path, 'utf8');
180
+ let marker;
181
+ try {
182
+ marker = JSON.parse(raw);
183
+ }
184
+ catch {
185
+ // Exclusive creation already owns the step, including the interval
186
+ // before metadata is written or a crash during that write.
187
+ if (now - (await stat(path)).mtimeMs > 24 * 3600_000) {
188
+ await rm(path, { force: true });
189
+ continue;
190
+ }
191
+ const epoch = Number(name.split('.').at(-2));
192
+ validTime(epoch);
193
+ claims.set(path, epoch);
194
+ continue;
195
+ }
196
+ validTime(marker.attemptAt);
197
+ validTime(marker.resetAt);
198
+ if (now - marker.attemptAt > 24 * 3600_000 || marker.resetAt <= now) {
199
+ await rm(path, { force: true });
200
+ continue;
201
+ }
202
+ claims.set(path, marker.attemptAt);
203
+ }
204
+ catch (error) {
205
+ if (error.code !== 'ENOENT')
206
+ throw error;
207
+ }
208
+ }
209
+ return claims;
210
+ }
211
+ catch (error) {
212
+ if (error.code === 'ENOENT')
213
+ return claims;
214
+ throw new SubscriptionUsageStoreError();
215
+ }
216
+ }
217
+ async #claimVerification(key, windows, observedAt) {
218
+ try {
219
+ await mkdir(join(this.#directory, 'claims'), { recursive: true, mode: 0o700 });
220
+ const markers = new Map(windows.map(w => [this.#claimPath(key, w, observedAt), w]));
221
+ for (const [path, window] of [...markers].sort(([a], [b]) => a.localeCompare(b))) {
222
+ const file = await open(path, 'wx', 0o600);
223
+ try {
224
+ await file.writeFile(JSON.stringify({ attemptAt: observedAt, resetAt: rejectionResetAt(window, window.observedAt ?? observedAt) }));
225
+ }
226
+ finally {
227
+ await file.close();
228
+ }
229
+ }
230
+ return true;
231
+ }
232
+ catch (error) {
233
+ if (error.code === 'EEXIST')
234
+ return false;
235
+ // A marker remains spent even if the JSON write fails. Readers consult
236
+ // markers as well, so restarting cannot launch the same verification.
237
+ throw new SubscriptionUsageStoreError();
238
+ }
239
+ }
142
240
  async #load(path, fallback) {
143
241
  try {
144
242
  const raw = await readFile(path, 'utf8');
@@ -169,6 +267,49 @@ export class FileSubscriptionUsageStore {
169
267
  await rm(temporary, { force: true }).catch(() => { });
170
268
  }
171
269
  }
270
+ #log(message) { logger.info(`[Pool] ${message}`); }
271
+ #logEvidence(previous, window, usage, now, threshold) {
272
+ const evidence = rejectionEvidence(usage, window, now);
273
+ if (evidence && evidence.percent < threshold && evidence.observedAt !== previous?.verificationEvidence?.observedAt &&
274
+ !(window.lastVerificationAttemptAt !== undefined && window.lastVerificationAttemptAt >= (window.observedAt ?? now))) {
275
+ this.#log(`rejection marked verifiable window=${window.window}/${window.scope} count=${window.rejectionCount ?? 1} evidence=${evidence.source}:${evidence.status}:${evidence.percent}% observedAt=${new Date(evidence.observedAt).toISOString()} eligibleAt=${new Date(verificationEligibleAt(window, window.observedAt ?? now)).toISOString()}`);
276
+ }
277
+ }
278
+ async #mergeUsage(key, state, previous, incoming, now) {
279
+ const path = this.#path(key, false);
280
+ const account = await this.#load(path, { reactive: [] });
281
+ account.reactive = safeReactive(account.reactive);
282
+ if (incoming)
283
+ incoming = { ...incoming, windows: incoming.windows.map(w => {
284
+ if (w.status !== 'rejected')
285
+ return w;
286
+ const at = w.observedAt ?? incoming.observedAt;
287
+ const prior = account.reactive.map(reactiveUsageWindow).find(r => r.window === w.window && r.scope === w.scope && r.observedAt <= at && r.resetAt > at);
288
+ return prior ? { ...w, rejectionCount: Math.max(w.rejectionCount ?? 1, (prior.rejectionCount ?? 1) + Number(prior.observedAt < at)) } : w;
289
+ }) };
290
+ state.usage = mergePersistedUsageObservations(previous, incoming, now);
291
+ const threshold = key.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT;
292
+ if (state.usage)
293
+ state.usage = { ...state.usage, windows: state.usage.windows.map(w => w.status === 'rejected' ? { ...w, rejectionCount: w.rejectionCount ?? 1 } : w) };
294
+ for (const window of state.usage?.windows ?? []) {
295
+ if (window.status !== 'rejected')
296
+ continue;
297
+ const old = previous?.windows.find(w => w.status === 'rejected' && w.window === window.window && w.scope === window.scope);
298
+ this.#logEvidence(old, window, state.usage, now, threshold);
299
+ if (old?.lastVerificationAttemptAt !== undefined && window.observedAt > old.observedAt)
300
+ this.#log(`verification re-rejected window=${window.window}/${window.scope} count=${window.rejectionCount ?? 1} eligibleAt=${new Date(verificationEligibleAt(window, window.observedAt)).toISOString()}`);
301
+ }
302
+ const reactive = safeReactive(account.reactive).map(r => {
303
+ if (!r.quota.window)
304
+ return r;
305
+ const window = reactiveUsageWindow(r);
306
+ const verificationEvidence = rejectionEvidence(state.usage, window, now);
307
+ this.#logEvidence(window, { ...window, verificationEvidence }, state.usage, now, threshold);
308
+ return { ...r, verificationEvidence };
309
+ });
310
+ if (JSON.stringify(reactive) !== JSON.stringify(account.reactive))
311
+ await this.#save(path, { reactive });
312
+ }
172
313
  async read(key) {
173
314
  const [version, account] = await Promise.all([
174
315
  this.#load(this.#path(key, true), {}),
@@ -177,19 +318,32 @@ export class FileSubscriptionUsageStore {
177
318
  // Corrupt reactive data must never silently resurrect an exhausted account.
178
319
  if (!Array.isArray(account.reactive) || account.reactive.some(r => !r || !Number.isFinite(r.observedAt) || !r.quota))
179
320
  throw new SubscriptionUsageStoreError();
180
- return { ...safeVersion(version), reactive: safeReactive(account.reactive) };
321
+ const state = { ...safeVersion(version), reactive: safeReactive(account.reactive) };
322
+ const claims = await this.#claimMarkers(key);
323
+ const claimedWindow = (w, fallbackAt) => {
324
+ const attemptAt = claims.get(this.#claimPath(key, w, fallbackAt));
325
+ return w.status === 'rejected' && attemptAt !== undefined ? { ...w, lastVerificationAttemptAt: Math.max(w.lastVerificationAttemptAt ?? 0, attemptAt) } : w;
326
+ };
327
+ if (state.usage)
328
+ state.usage = { ...state.usage, windows: state.usage.windows.map(w => claimedWindow(w, state.usage.observedAt)) };
329
+ state.reactive = state.reactive.map(r => ({ ...r, lastVerificationAttemptAt: claimedWindow(reactiveUsageWindow(r), r.observedAt).lastVerificationAttemptAt }));
330
+ return state;
181
331
  }
182
332
  async recordPassive(key, rateLimitInfo, observedAt, options) {
183
333
  validTime(observedAt);
184
334
  const path = this.#path(key, true);
185
- await serial(path, async () => {
335
+ await serial(this.#path(key, false), async () => {
186
336
  const state = safeVersion(await this.#load(path, {}));
187
337
  if (state.passive && state.passive.observedAt > observedAt && !options?.expireFutureObservations)
188
338
  return;
189
339
  // Retain Fable observation clocks even when the latest report omits its meter.
190
340
  const previous = state.usage ?? usageStateForSelection({ ...state, reactive: [] }, observedAt).usage;
191
341
  state.passive = { rateLimitInfo: safeRateLimitInfo(rateLimitInfo), observedAt };
192
- state.usage = mergePersistedUsageObservations(previous, normalizePassiveUsage(state.passive.rateLimitInfo, observedAt), observedAt);
342
+ const incoming = normalizePassiveUsage(state.passive.rateLimitInfo, observedAt);
343
+ const status = state.passive.rateLimitInfo.status;
344
+ if (status === 'allowed' || status === 'allowed_warning')
345
+ incoming.windows = incoming.windows.map(w => ({ ...w, status: w.status ?? status }));
346
+ await this.#mergeUsage(key, state, previous, incoming, observedAt);
193
347
  await this.#save(path, state);
194
348
  });
195
349
  }
@@ -199,24 +353,41 @@ export class FileSubscriptionUsageStore {
199
353
  await serial(path, async () => {
200
354
  const state = await this.#load(path, { reactive: [] });
201
355
  state.reactive = safeReactive(state.reactive).filter(r => !options?.expireFutureObservations || r.observedAt <= observedAt);
202
- const next = { observedAt, quota: safeQuota(quota) };
356
+ const next = { observedAt, quota: safeQuota(quota), rejectionCount: 1 };
203
357
  const until = reactiveEligibleAt;
204
358
  const sameScope = (r) => r.quota.modelScope === next.quota.modelScope && r.quota.window === next.quota.window;
205
359
  const previous = state.reactive.find(sameScope);
360
+ const version = safeVersion(await this.#load(this.#path(key, true), {}));
361
+ const window = reactiveUsageWindow(next);
362
+ const passive = version.usage?.windows.find(w => w.status === 'rejected' && w.window === window.window && w.scope === window.scope &&
363
+ (w.observedAt ?? version.usage.observedAt) <= observedAt && rejectionResetAt(w, w.observedAt ?? version.usage.observedAt) > observedAt);
206
364
  const reactive = state.reactive.filter(r => !sameScope(r) && until(r) > observedAt);
207
365
  const hasExplicitReset = (r) => r.quota.resetAt !== undefined && Date.parse(r.quota.resetAt) > r.observedAt;
208
366
  // A recheck is not a reset. New matching quota evidence can replace that
209
367
  // provisional deadline with its explicit reset, without clearing a known
210
368
  // active reset or accepting an older observation as revalidation.
211
369
  const revalidated = previous && next.observedAt >= previous.observedAt && !hasExplicitReset(previous) && hasExplicitReset(next);
212
- reactive.push(previous && !revalidated && until(previous) > until(next) ? previous : next);
370
+ if (previous && next.observedAt < previous.observedAt)
371
+ reactive.push(previous);
372
+ else if (previous && next.observedAt === previous.observedAt && !revalidated)
373
+ reactive.push(previous);
374
+ else {
375
+ next.rejectionCount = Math.max(previous && until(previous) > observedAt ? (previous.rejectionCount ?? 1) + Number(previous.observedAt < observedAt) : 1, passive ? (passive.rejectionCount ?? 1) + Number((passive.observedAt ?? version.usage.observedAt) < observedAt) : 1);
376
+ if (previous && !next.quota.resetAt && until(previous) > until(next))
377
+ next.quota.resetAt = previous.quota.resetAt;
378
+ if (!next.quota.resetAt && passive?.resetAt)
379
+ next.quota.resetAt = new Date(passive.resetAt).toISOString();
380
+ reactive.push(next);
381
+ if (previous?.lastVerificationAttemptAt !== undefined || passive?.lastVerificationAttemptAt !== undefined)
382
+ this.#log(`verification re-rejected window=${next.quota.window ?? 'seven_day'}/${next.quota.modelScope ?? 'global'} count=${next.rejectionCount} eligibleAt=${new Date(verificationEligibleAt(reactiveUsageWindow(next), observedAt)).toISOString()}`);
383
+ }
213
384
  await this.#save(path, { reactive });
214
385
  });
215
386
  }
216
387
  async beginProbe(key, observedAt) {
217
388
  validTime(observedAt);
218
389
  const path = this.#path(key, true);
219
- return serial(path, async () => {
390
+ return serial(this.#path(key, false), async () => {
220
391
  const state = safeVersion(await this.#load(path, {}));
221
392
  if (state.probe)
222
393
  return false;
@@ -229,7 +400,7 @@ export class FileSubscriptionUsageStore {
229
400
  async claimLocalProbe(key, observedAt, minIntervalMs) {
230
401
  validTime(observedAt);
231
402
  const path = this.#path(key, true);
232
- return serial(path, async () => {
403
+ return serial(this.#path(key, false), async () => {
233
404
  const state = safeVersion(await this.#load(path, {}));
234
405
  if (state.localProbeClaimedAt !== undefined && observedAt >= state.localProbeClaimedAt &&
235
406
  observedAt - state.localProbeClaimedAt < minIntervalMs)
@@ -259,15 +430,77 @@ export class FileSubscriptionUsageStore {
259
430
  async finishProbe(key, observation) {
260
431
  validTime(observation.observedAt);
261
432
  const path = this.#path(key, true);
262
- await serial(path, async () => {
433
+ await serial(this.#path(key, false), async () => {
263
434
  const state = safeVersion(await this.#load(path, {}));
264
435
  // Network completion order must not discard a newer stored snapshot.
265
436
  const mergeAt = Math.max(observation.observedAt, state.usage?.observedAt ?? 0, state.passive?.observedAt ?? 0);
266
437
  // Retain Fable observation clocks even when the latest report omits its meter.
267
438
  const previous = state.usage ?? usageStateForSelection({ ...state, reactive: [] }, mergeAt).usage;
268
439
  state.probe = safeProbe(observation);
269
- state.usage = mergePersistedUsageObservations(previous, state.probe.usage, mergeAt);
440
+ await this.#mergeUsage(key, state, previous, state.probe.usage, mergeAt);
270
441
  await this.#save(path, state);
271
442
  });
272
443
  }
444
+ async beginVerification(key, model, observedAt) {
445
+ validTime(observedAt);
446
+ return serial(this.#path(key, false), async () => {
447
+ const stored = await this.read(key);
448
+ const state = usageStateForSelection(stored, observedAt);
449
+ const selected = selectSubscriptionAccount({ pool: { kind: 'ready', accounts: [key] }, states: { [key.accountId]: state }, model, now: observedAt });
450
+ if (selected.kind !== 'selected')
451
+ return undefined;
452
+ const windows = verificationWindows(key, state, model, observedAt);
453
+ if (!windows.length)
454
+ return [];
455
+ if (!await this.#claimVerification(key, windows, observedAt))
456
+ return undefined;
457
+ const matches = (w) => windows.some(v => v.window === w.window && v.scope === w.scope && v.observedAt === (w.observedAt ?? stored.usage?.observedAt ?? state.usage?.observedAt));
458
+ const version = safeVersion(await this.#load(this.#path(key, true), {}));
459
+ version.usage ??= state.usage;
460
+ if (version.usage)
461
+ version.usage = { ...version.usage, windows: version.usage.windows.map(w => matches(w) ? { ...w, lastVerificationAttemptAt: observedAt } : w) };
462
+ const reactive = stored.reactive.map(r => matches(reactiveUsageWindow(r)) ? { ...r, lastVerificationAttemptAt: observedAt } : r);
463
+ await this.#save(this.#path(key, true), version);
464
+ await this.#save(this.#path(key, false), { reactive });
465
+ for (const w of windows)
466
+ this.#log(`verification attempt started window=${w.window}/${w.scope} count=${w.rejectionCount ?? 1} attemptAt=${new Date(observedAt).toISOString()}`);
467
+ return windows;
468
+ });
469
+ }
470
+ async completeVerification(key, attemptAt, observedAt) {
471
+ validTime(attemptAt);
472
+ validTime(observedAt);
473
+ await serial(this.#path(key, false), async () => {
474
+ const stored = await this.read(key);
475
+ const cleared = [];
476
+ const canClear = (w) => w.status === 'rejected' && w.lastVerificationAttemptAt === attemptAt &&
477
+ (w.observedAt ?? stored.usage?.observedAt ?? observedAt) <= attemptAt && attemptAt <= observedAt;
478
+ const version = safeVersion(await this.#load(this.#path(key, true), {}));
479
+ const windows = version.usage?.windows.filter(w => { if (!canClear(w))
480
+ return true; cleared.push({ ...w, observedAt: w.observedAt ?? version.usage.observedAt }); return false; });
481
+ if (version.usage && windows)
482
+ version.usage = { ...version.usage, windows };
483
+ const reactive = stored.reactive.filter(r => { const w = reactiveUsageWindow(r); if (!canClear(w))
484
+ return true; cleared.push(w); return false; });
485
+ // Raw snapshots are also selection inputs; remove the cleared rejection there.
486
+ const matches = (w, fallback) => cleared.some(c => c.window === w.window && c.scope === w.scope && (w.observedAt ?? fallback) === c.observedAt);
487
+ if (version.passive && normalizePassiveUsage(version.passive.rateLimitInfo, version.passive.observedAt).windows.some(w => w.status === 'rejected' && matches(w, version.passive.observedAt))) {
488
+ // The rejected percentage is not a successful measurement. Keeping it
489
+ // as status-less usage could immediately re-block the cleared account.
490
+ delete version.passive;
491
+ }
492
+ if (version.probe?.usage)
493
+ version.probe.usage = { ...version.probe.usage, windows: version.probe.usage.windows.filter(w => w.status !== 'rejected' || !matches(w, version.probe.usage.observedAt)) };
494
+ await this.#save(this.#path(key, true), version);
495
+ await this.#save(this.#path(key, false), { reactive });
496
+ const markers = await this.#claimMarkers(key);
497
+ await Promise.all([...markers.keys()].filter(path => cleared.some(w => {
498
+ const prefix = join(this.#directory, 'claims', `${encodeURIComponent(key.accountId)}.${w.scope}.${w.window}.`);
499
+ const epoch = Number(path.slice(prefix.length, -'.claim'.length));
500
+ return path.startsWith(prefix) && epoch <= verificationEligibleAt(w, w.observedAt ?? observedAt);
501
+ })).map(path => rm(path, { force: true })));
502
+ for (const w of cleared)
503
+ this.#log(`verification cleared rejection window=${w.window}/${w.scope} count=${w.rejectionCount ?? 1}`);
504
+ });
505
+ }
273
506
  }
package/dist/types.d.ts CHANGED
@@ -56,6 +56,8 @@ export interface TeamVibeQueueMessage {
56
56
  values?: Record<string, string>;
57
57
  };
58
58
  teamvibe: {
59
+ /** Workspace/channel IANA timezone, when supplied by message routing. */
60
+ timeZone?: string;
59
61
  workspaceId: string;
60
62
  botId: string;
61
63
  botToken: string;