@teamvibe/poller 0.8.6-rc.3 → 0.8.6-rc.5

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.
@@ -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
- const quota = candidates.find(candidate => candidate.reason === 'weekly_limit') ?? candidates[0];
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;
@@ -1,3 +1,4 @@
1
+ import { blockingUsageWindows, normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
1
2
  import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
2
3
  import { emitPoolSelection, accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
3
4
  import { spawn } from 'child_process';
@@ -1593,6 +1594,19 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1593
1594
  }
1594
1595
  },
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
+ };
1596
1610
  let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
1597
1611
  const attempt = async (id, first) => {
1598
1612
  if (prepared?.account && id && poolRuntime) {
@@ -1608,8 +1622,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1608
1622
  if (!runtime?.resumePending)
1609
1623
  sawSendMessageCall = false;
1610
1624
  const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1611
- const result = prepared?.account && spawned.rateLimitInfo?.status === 'rejected'
1612
- ? { ...spawned, success: false } : spawned;
1625
+ const result = prepared?.account
1626
+ ? { ...spawned, quota: quotaForModel(spawned.quota), success: rejectedForModel(spawned.rateLimitInfo) ? false : spawned.success } : spawned;
1613
1627
  sawSendMessageCall ||= result.sawSendMessageCall === true;
1614
1628
  if (prepared?.account) {
1615
1629
  if (poolAttempts.length >= 1000)
@@ -1643,7 +1657,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1643
1657
  retainWorktree = false;
1644
1658
  return result;
1645
1659
  }
1646
- if ((result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller) {
1660
+ if ((result.quota || rejectedForModel(result.rateLimitInfo)) && !result.killedByPoller) {
1647
1661
  runtime.resumePending = true;
1648
1662
  retainWorktree = true;
1649
1663
  }
@@ -1667,7 +1681,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1667
1681
  let switched = false;
1668
1682
  const finishQuota = async (initial) => {
1669
1683
  let result = initial;
1670
- while (prepared?.account && (result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller && poolRuntime && runtime) {
1684
+ while (prepared?.account && (result.quota || rejectedForModel(result.rateLimitInfo)) && !result.killedByPoller && poolRuntime && runtime) {
1671
1685
  attemptedAccounts.add(prepared.account.accountId);
1672
1686
  if (poolRuntime.shouldStop())
1673
1687
  throw new PoolDeferredError('infrastructure', { suppressNotice: 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
- const pause = deferPool(threadId, error.eligibleAt, error.resetAt);
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);
@@ -21,7 +21,7 @@ export interface PoolSelectionCandidate {
21
21
  reason: string;
22
22
  score?: number;
23
23
  headroom?: number;
24
- rateWindow?: Pick<UsageWindow, 'window' | 'scope'>;
24
+ rateWindow?: Pick<UsageWindow, 'window' | 'scope' | 'percent'>;
25
25
  eligibleAt?: number;
26
26
  }
27
27
  /** The log and transport share a single explanation of the same selection snapshot. */
@@ -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,6 +42,20 @@ 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
+ function skippedSelectionReason(candidate, selected) {
46
+ if (candidate.provenance === 'unknown' && selected.provenance === 'known')
47
+ return 'known observation preferred';
48
+ if (candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score < selected.score)
49
+ return 'lower headroom per hour';
50
+ if (candidate.provenance === selected.provenance && selected.pinned)
51
+ return 'pinned thread kept (within tolerance)';
52
+ if (candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score === selected.score)
53
+ return 'equal headroom, account id order';
54
+ if (candidate.provenance === 'unknown' && selected.provenance === 'unknown')
55
+ return 'no measurements, account id order';
56
+ // Should be unreachable: the policy explains every available skipped candidate above.
57
+ return 'deterministic account order';
58
+ }
45
59
  /** The log and transport share a single explanation of the same selection snapshot. */
46
60
  export function poolSelectionCandidates(pool, states, model, now, selected) {
47
61
  if (pool.kind !== 'ready')
@@ -53,14 +67,15 @@ export function poolSelectionCandidates(pool, states, model, now, selected) {
53
67
  const candidate = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states, model, now });
54
68
  if (candidate.kind === 'deferred') {
55
69
  const usageOnly = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states: { [account.accountId]: { usage: states[account.accountId]?.usage } }, model, now });
56
- return { ...base, reason: usageOnly.kind === 'deferred' ? `usage >=${account.useFullQuota ? 100 : 90}%` : 'reactive quota', eligibleAt: candidate.eligibleAt };
70
+ const window = blockingUsageWindows(account, states[account.accountId], model, now)[0];
71
+ 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 };
57
72
  }
58
73
  const metrics = candidate.kind === 'selected' && candidate.provenance === 'known'
59
- ? { score: candidate.score, headroom: (account.useFullQuota ? 100 : 90) - candidate.rateWindow.percent, rateWindow: { window: candidate.rateWindow.window, scope: candidate.rateWindow.scope } } : {};
74
+ ? { score: candidate.score, headroom: (account.useFullQuota ? 100 : 90) - candidate.rateWindow.percent, rateWindow: { window: candidate.rateWindow.window, scope: candidate.rateWindow.scope, percent: candidate.rateWindow.percent } } : {};
60
75
  if (selected.kind === 'selected' && account.accountId === selected.account.accountId)
61
76
  return { ...base, ...metrics, outcome: 'selected', reason: 'selected' };
62
77
  const reason = candidate.kind === 'selected' && selected.kind === 'selected'
63
- ? 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'
78
+ ? skippedSelectionReason(candidate, selected)
64
79
  : 'unavailable';
65
80
  return { ...base, ...metrics, reason };
66
81
  });
@@ -71,7 +86,7 @@ export function logPoolSelection(log, pool, states, model, now, selected, logAll
71
86
  for (const candidate of poolSelectionCandidates(pool, states, model, now, selected)) {
72
87
  if (candidate.outcome !== 'skipped' || candidate.reason === 'unavailable')
73
88
  continue;
74
- poolInfo(log, `skipped account=${JSON.stringify(candidate.accountLabel)} reason=${candidate.reason}${candidate.eligibleAt === undefined ? '' : ` eligibleAt=${new Date(candidate.eligibleAt).toISOString()}`}`);
89
+ 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()}`}`);
75
90
  }
76
91
  if (selected.kind === 'deferred' && logAllExhausted) {
77
92
  // Ask the same policy about each observation so model scope, freshness and
@@ -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. */
@@ -86,6 +88,7 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
86
88
  account: SubscriptionAccount;
87
89
  provenance: "known";
88
90
  source: "passive" | "probe";
91
+ pinned?: true;
89
92
  } & HeadroomScore & {
90
93
  model: string | undefined;
91
94
  modelSource: import("./claude-spawner.js").ModelSource;
@@ -100,6 +103,7 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
100
103
  account: SubscriptionAccount;
101
104
  provenance: "unknown";
102
105
  uncertainApplicability: boolean;
106
+ pinned?: true;
103
107
  } & {
104
108
  model: string | undefined;
105
109
  modelSource: import("./claude-spawner.js").ModelSource;
@@ -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, usageWindowExpiresAt, reactiveEligibleAt, PIN_HEADROOM_SWITCH_RELATIVE, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
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
- for (const account of accounts) {
57
- if (!account.enabled || account.accountId === selectedAccountId || isSubscriptionAccountRunning(account))
58
- continue;
59
- // Fire-and-forget ALL probe work, including disk claims and cleanup. No
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
- const now = Date.now();
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=${probe?.controller.signal.aborted ? 'cancelled reason=account selected' : 'unknown'}`);
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,6 +146,11 @@ 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
156
  if (pool.kind === 'ready' && currentAccountId && currentAccountId !== selected.account.accountId) {
@@ -131,11 +161,6 @@ export async function preparePool(msg, resolved, pin, fetched, log, probeOptions
131
161
  if (log)
132
162
  poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=${previousSelection?.kind === 'selected' ? 'better headroom' : 'pinned account unavailable'}`);
133
163
  }
134
- if (pool.kind === 'ready')
135
- probeUnknownAccounts(pool.accounts, selected.account.accountId, resolved, store, {
136
- cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent,
137
- log: line => (log ?? logger).info(line), ...probeOptions, store,
138
- }, msg.teamvibe.channelId);
139
164
  return { account: selected.account, store, pool, selection: selected };
140
165
  }
141
166
  if (selected.kind === 'no_accounts_configured')
@@ -171,16 +196,12 @@ async function poolUsageNotice(pool, resolved, store) {
171
196
  const usage = state.usage;
172
197
  const windows = usage && usage.observedAt <= now
173
198
  ? usage.windows.filter(w => now < usageWindowExpiresAt(usage, w)) : [];
174
- const rejection = selectSubscriptionAccount({
175
- pool: { kind: 'ready', accounts: [account] },
176
- states: { [account.accountId]: { usage: usage ? { ...usage, windows: usage.windows.filter(w => w.status === 'rejected') } : undefined } },
177
- model: resolved.model, now,
178
- });
179
- const rejected = rejection.kind === 'deferred' || (state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false);
180
- 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));
181
202
  const resets = blockers.map(w => w.resetAt);
182
203
  return {
183
- 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,
184
205
  thresholdBlocked: blockers.length > 0,
185
206
  resetAt: resets.length && resets.every((r) => r !== undefined) ? Math.max(...resets) : undefined,
186
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,28 @@ 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
- /** Latest measurements plus durable rejections; omission or success never clears a hold. */
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
+ /** Genuine Fable data replaces raw uncertain windows observed at or before it.
67
+ * Deduplicate uncertain windows before splitting measurements from rejections.
68
+ */
69
+ 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. */
58
71
  export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
72
+ /** Preserve last-known meters, projecting legacy identity before its global companion is replaced. */
73
+ export declare function mergePersistedUsageObservations(previous: UsageObservation | undefined, incoming: UsageObservation | undefined, now: number): UsageObservation | undefined;
59
74
  export interface ReactiveExhaustion {
60
75
  observedAt: number;
61
76
  quota: ClaudeQuotaFailure;
@@ -74,11 +89,13 @@ export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = ({
74
89
  account: A;
75
90
  provenance: 'known';
76
91
  source: 'passive' | 'probe';
92
+ pinned?: true;
77
93
  } & HeadroomScore) | {
78
94
  kind: 'selected';
79
95
  account: A;
80
96
  provenance: 'unknown';
81
97
  uncertainApplicability: boolean;
98
+ pinned?: true;
82
99
  } | {
83
100
  kind: 'deferred';
84
101
  eligibleAt: number;
@@ -101,3 +118,7 @@ export declare function selectSubscriptionAccount<A extends AccountIdentity>(inp
101
118
  currentAccountTolerance?: number;
102
119
  now: number;
103
120
  }): SelectionOutcome<A>;
121
+ /** Explain actual blockers with the same scope, clocks and thresholds as selection. */
122
+ export declare function blockingUsageWindows(account: AccountIdentity, state: AccountUsageState | undefined, model: string | undefined, now: number): (UsageWindow & {
123
+ eligibleAt: number;
124
+ })[];
@@ -10,23 +10,134 @@ 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
- /** Latest measurements plus durable rejections; omission or success never clears a hold. */
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 replaces raw uncertain windows observed at or before it.
63
+ * Deduplicate uncertain windows before splitting measurements from rejections.
64
+ */
65
+ export function supersedeLegacyFableProjections(observations, now) {
66
+ // Preserve explicit-scope compatibility for already projected legacy Fable data.
67
+ let newestFable = -Infinity;
68
+ let newestGenuine = -Infinity;
69
+ for (const usage of observations) {
70
+ if (!usage || usage.observedAt > now)
71
+ continue;
72
+ for (const w of usage.windows) {
73
+ const observedAt = w.observedAt ?? usage.observedAt;
74
+ if (w.window === 'seven_day' && w.scope === 'fable' &&
75
+ w.kind !== 'legacy-uncertain-seven-day' && observedAt <= now)
76
+ newestFable = Math.max(newestFable, observedAt);
77
+ if (w.window === 'seven_day' && w.scope === 'fable' &&
78
+ w.kind === 'seven_day_overage_included' && observedAt <= now)
79
+ newestGenuine = Math.max(newestGenuine, observedAt);
80
+ }
81
+ }
82
+ // Reporter versions and missing kinds cannot date raw uncertain windows.
83
+ // Keep newer evidence unprojected so its rejection still holds every family.
84
+ const projected = observations.map(usage => {
85
+ const filtered = usage && ({
86
+ ...usage,
87
+ windows: usage.windows.filter(w => !(newestGenuine > -Infinity &&
88
+ w.window === 'seven_day' && w.scope === 'uncertain' &&
89
+ (w.observedAt ?? usage.observedAt) <= newestGenuine)),
90
+ });
91
+ return newestGenuine > -Infinity ? filtered : projectLegacyFableUsage(filtered);
92
+ });
93
+ const newestUncertain = new Map();
94
+ projected.forEach((usage, observation) => {
95
+ if (!usage || usage.observedAt > now)
96
+ return;
97
+ usage.windows.forEach((w, window) => {
98
+ const observedAt = w.observedAt ?? usage.observedAt;
99
+ if (w.scope !== 'uncertain' || observedAt > now)
100
+ return;
101
+ const previous = newestUncertain.get(w.window);
102
+ if (!previous || observedAt >= previous.observedAt)
103
+ newestUncertain.set(w.window, { observation, window, observedAt });
104
+ });
105
+ });
106
+ return projected.map((usage, observation) => usage && ({
107
+ ...usage,
108
+ windows: usage.windows.filter((w, window) => {
109
+ if (w.window === 'seven_day' && newestFable > -Infinity && (w.scope === 'fable' && w.kind === 'legacy-uncertain-seven-day' &&
110
+ (w.observedAt ?? usage.observedAt) < newestFable))
111
+ return false;
112
+ if (w.scope !== 'uncertain')
113
+ return true;
114
+ const newest = newestUncertain.get(w.window);
115
+ return newest?.observation === observation && newest.window === window;
116
+ }),
117
+ }));
118
+ }
119
+ /** Latest measurements plus durable genuine rejections; omission or success never clears a genuine hold. */
17
120
  export function mergeUsageObservations(observations, now) {
18
121
  let latest;
19
122
  const rejected = new Map();
20
- for (const usage of observations) {
123
+ const scopedExhausted = new Map();
124
+ for (const usage of supersedeLegacyFableProjections(observations, now)) {
21
125
  if (!usage || usage.observedAt > now)
22
126
  continue;
23
127
  if (!latest || usage.observedAt >= latest.observedAt)
24
128
  latest = usage;
25
129
  for (const w of usage.windows) {
26
130
  const observedAt = w.observedAt ?? usage.observedAt;
27
- if (w.status !== 'rejected' || observedAt > now)
131
+ if (observedAt > now)
28
132
  continue;
29
133
  const key = `${w.window}:${w.scope}`;
134
+ if (w.scope !== 'global' && w.scope !== 'uncertain' && w.status !== 'rejected') {
135
+ const previous = scopedExhausted.get(key);
136
+ if (!previous || observedAt >= previous.observedAt)
137
+ scopedExhausted.set(key, { ...w, observedAt, source: w.source ?? usage.source });
138
+ }
139
+ if (w.status !== 'rejected')
140
+ continue;
30
141
  const previous = rejected.get(key);
31
142
  if (!previous || observedAt >= previous.observedAt)
32
143
  rejected.set(key, { ...w, observedAt, source: w.source ?? usage.source });
@@ -35,16 +146,64 @@ export function mergeUsageObservations(observations, now) {
35
146
  if (!latest)
36
147
  return undefined;
37
148
  const active = [...rejected.values()].filter(w => now < (w.resetAt ?? w.observedAt + REJECTED_BLOCK_MS));
149
+ const retained = [...scopedExhausted.values()].filter(w => isScopedExhaustion(w) &&
150
+ !latest.windows.some(other => other.window === w.window && other.scope === w.scope) &&
151
+ !active.some(other => other.window === w.window && other.scope === w.scope) &&
152
+ now < usageWindowExpiresAt(latest, w));
38
153
  return {
39
154
  ...latest,
40
155
  windows: [
41
156
  ...latest.windows.filter(w => w.status !== 'rejected' &&
42
- (w.observedAt ?? latest.observedAt) === latest.observedAt &&
43
- (w.source ?? latest.source) === latest.source).slice(0, 133 - active.length),
157
+ (w.observedAt ?? latest.observedAt) <= now &&
158
+ (isLatestMeasurement(latest, w) || (isScopedExhaustion(w) && now < usageWindowExpiresAt(latest, w))))
159
+ .slice(0, Math.max(0, 133 - active.length - retained.length)),
160
+ ...retained,
44
161
  ...active,
45
162
  ],
46
163
  };
47
164
  }
165
+ /** Preserve last-known meters, projecting legacy identity before its global companion is replaced. */
166
+ export function mergePersistedUsageObservations(previous, incoming, now) {
167
+ // Apply the same replacement before retaining last-known meters for storage.
168
+ [previous, incoming] = supersedeLegacyFableProjections([previous, incoming], now);
169
+ incoming ??= previous;
170
+ if (!incoming)
171
+ return undefined;
172
+ const merged = mergeUsageObservations([previous, incoming], now);
173
+ const rejected = merged?.windows.filter(w => w.status === 'rejected') ?? [];
174
+ const windows = (previous?.windows ?? []).filter(w => w.status !== 'rejected').map((w) => ({
175
+ ...w,
176
+ observedAt: w.observedAt ?? previous.observedAt,
177
+ source: w.source ?? previous.source,
178
+ }));
179
+ const groups = new Map();
180
+ for (const window of incoming.windows.filter(w => w.status !== 'rejected')) {
181
+ const group = `${window.window}:${window.scope}`;
182
+ const values = groups.get(group) ?? [];
183
+ values.push({
184
+ ...window,
185
+ observedAt: window.observedAt ?? incoming.observedAt,
186
+ source: window.source ?? incoming.source,
187
+ });
188
+ groups.set(group, values);
189
+ }
190
+ for (const [group, values] of groups) {
191
+ const matches = (w) => `${w.window}:${w.scope}` === group;
192
+ const old = windows.filter(matches);
193
+ const newest = Math.max(...values.map((w) => w.observedAt));
194
+ // A failed/incomplete observation must not erase a last-known measurement.
195
+ if (old.length &&
196
+ (newest <= Math.max(...old.map((w) => w.observedAt)) ||
197
+ values.every((w) => w.percent === undefined)))
198
+ continue;
199
+ for (let i = windows.length - 1; i >= 0; i--)
200
+ if (matches(windows[i]))
201
+ windows.splice(i, 1);
202
+ windows.push(...values);
203
+ }
204
+ const latest = previous && previous.observedAt > incoming.observedAt ? previous : incoming;
205
+ return { ...latest, windows: [...windows.slice(0, 133 - rejected.length), ...rejected] };
206
+ }
48
207
  export function record(value) {
49
208
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
50
209
  }
@@ -194,7 +353,7 @@ export function selectSubscriptionAccount(input) {
194
353
  if (input.now < until)
195
354
  blockers.push(until);
196
355
  }
197
- const usage = state?.usage;
356
+ const usage = supersedeLegacyFableProjections([state?.usage], input.now)[0];
198
357
  // A provider rejection outlives snapshot freshness. Never score it as headroom.
199
358
  if (usage && usage.observedAt <= input.now) {
200
359
  for (const w of usage.windows) {
@@ -208,7 +367,12 @@ export function selectSubscriptionAccount(input) {
208
367
  blockers.push(until);
209
368
  }
210
369
  }
211
- const windows = usage && usage.observedAt <= input.now ? usage.windows.filter(w => w.status !== 'rejected' && (w.observedAt ?? usage.observedAt) === usage.observedAt && (w.source ?? usage.source) === usage.source && applicable(w.scope, input.model) && input.now < usageWindowExpiresAt(usage, w)) : [];
370
+ const windows = usage && usage.observedAt <= input.now
371
+ ? usage.windows.filter(w => w.status !== 'rejected' &&
372
+ (w.observedAt ?? usage.observedAt) <= input.now &&
373
+ (isLatestMeasurement(usage, w) || isScopedExhaustion(w)) &&
374
+ applicable(w.scope, input.model) && input.now < usageWindowExpiresAt(usage, w))
375
+ : [];
212
376
  const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && input.model !== undefined && !family(input.model)));
213
377
  for (const w of windows) {
214
378
  if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
@@ -232,12 +396,32 @@ export function selectSubscriptionAccount(input) {
232
396
  const current = known.find(candidate => candidate.account.accountId === input.currentAccountId);
233
397
  if (current && known[0] && input.currentAccountTolerance !== undefined &&
234
398
  known[0].score < current.score * (1 + input.currentAccountTolerance))
235
- return { kind: 'selected', provenance: 'known', ...current };
399
+ return { kind: 'selected', provenance: 'known', ...current, pinned: true };
236
400
  // Known-first is only this spawn's transitional fallback: background CLI
237
401
  // probes measure non-selected unknowns so the next spawn can score them.
402
+ // Preserve pin provenance for equal scores (including zero) and unknowns too.
238
403
  if (known[0])
239
- return { kind: 'selected', provenance: 'known', ...known[0] };
404
+ return { kind: 'selected', provenance: 'known', ...known[0],
405
+ ...(known[0].account.accountId === input.currentAccountId ? { pinned: true } : {}) };
240
406
  if (unknown[0])
241
- return { kind: 'selected', provenance: 'unknown', ...unknown[0] };
407
+ return { kind: 'selected', provenance: 'unknown', ...unknown[0],
408
+ ...(input.currentAccountTolerance !== undefined && unknown[0].account.accountId === input.currentAccountId ? { pinned: true } : {}) };
242
409
  return { kind: 'deferred', eligibleAt: Math.min(...deadlines) };
243
410
  }
411
+ /** Explain actual blockers with the same scope, clocks and thresholds as selection. */
412
+ export function blockingUsageWindows(account, state, model, now) {
413
+ const pool = { kind: 'ready', accounts: [account] };
414
+ const usage = supersedeLegacyFableProjections([state?.usage], now)[0];
415
+ const windows = [];
416
+ for (const window of usage?.windows ?? []) {
417
+ const result = selectSubscriptionAccount({ pool, states: { [account.accountId]: { usage: { ...usage, windows: [window] } } }, model, now });
418
+ if (result.kind === 'deferred')
419
+ windows.push({ ...window, eligibleAt: result.eligibleAt });
420
+ }
421
+ 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 });
425
+ }
426
+ return windows;
427
+ }
@@ -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, mergePersistedUsageObservations, 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.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku)(?:-\d[\w.-]*)?$/)?.[1] : undefined;
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' });
@@ -186,9 +186,10 @@ export class FileSubscriptionUsageStore {
186
186
  const state = safeVersion(await this.#load(path, {}));
187
187
  if (state.passive && state.passive.observedAt > observedAt && !options?.expireFutureObservations)
188
188
  return;
189
- const previous = usageStateForSelection({ ...state, reactive: [] }, observedAt).usage;
189
+ // Retain Fable observation clocks even when the latest report omits its meter.
190
+ const previous = state.usage ?? usageStateForSelection({ ...state, reactive: [] }, observedAt).usage;
190
191
  state.passive = { rateLimitInfo: safeRateLimitInfo(rateLimitInfo), observedAt };
191
- state.usage = mergeUsageObservations([previous, normalizePassiveUsage(state.passive.rateLimitInfo, observedAt)], observedAt);
192
+ state.usage = mergePersistedUsageObservations(previous, normalizePassiveUsage(state.passive.rateLimitInfo, observedAt), observedAt);
192
193
  await this.#save(path, state);
193
194
  });
194
195
  }
@@ -262,9 +263,10 @@ export class FileSubscriptionUsageStore {
262
263
  const state = safeVersion(await this.#load(path, {}));
263
264
  // Network completion order must not discard a newer stored snapshot.
264
265
  const mergeAt = Math.max(observation.observedAt, state.usage?.observedAt ?? 0, state.passive?.observedAt ?? 0);
265
- const previous = usageStateForSelection({ ...state, reactive: [] }, mergeAt).usage;
266
+ // Retain Fable observation clocks even when the latest report omits its meter.
267
+ const previous = state.usage ?? usageStateForSelection({ ...state, reactive: [] }, mergeAt).usage;
266
268
  state.probe = safeProbe(observation);
267
- state.usage = mergeUsageObservations([previous, state.probe.usage], mergeAt);
269
+ state.usage = mergePersistedUsageObservations(previous, state.probe.usage, mergeAt);
268
270
  await this.#save(path, state);
269
271
  });
270
272
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.6-rc.3",
3
+ "version": "0.8.6-rc.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.6-rc.3",
9
+ "version": "0.8.6-rc.5",
10
10
  "dependencies": {
11
11
  "@aws-sdk/client-dynamodb": "3.1108.0",
12
12
  "@aws-sdk/client-sqs": "3.1108.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.6-rc.3",
3
+ "version": "0.8.6-rc.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {