@teamvibe/poller 0.8.4 → 0.8.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.
package/README.md CHANGED
@@ -95,3 +95,7 @@ In the environment the poller injects, the alias and account ID follow the selec
95
95
  ## How It Works
96
96
 
97
97
  The poller authenticates with the TeamVibe API using your token, then continuously polls an SQS queue for incoming messages from Slack. When a message arrives, it spawns a Claude Code session with the appropriate brain (knowledge base) and streams the response back to Slack.
98
+
99
+ ## Subscription usage freshness
100
+
101
+ Passive usage percentages remain usable lower bounds until each window's `resetAt`. Without a reset time, passive windows expire five minutes after observation. Probe windows retain their 60-second TTL. Reset windows are ignored; known accounts still rank before unknown accounts. A window at the account's threshold defers selection until its expiry (reset for passive windows, TTL fallback without a reset).
@@ -902,7 +902,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
902
902
  if (!runtime?.account && isQuotaPaused())
903
903
  throw new QuotaPausedError();
904
904
  if (runtime?.shouldStop())
905
- throw new PoolDeferredError();
905
+ throw new PoolDeferredError('infrastructure', { suppressNotice: true });
906
906
  const prompt = runtime?.resumePending ? 'Continue the pending work in this session from the last completed action. Do not repeat completed actions or messages.' : buildPrompt(msg, contextHint);
907
907
  // teamvibe.ai#293 bug 2: decides plain-text vs. stream-json stdin. Empty
908
908
  // `extraArgs`/single-chunk `stdinChunks` (the overwhelming majority of
@@ -1530,7 +1530,6 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1530
1530
  }
1531
1531
  const resumePending = poolRuntime?.pin?.resumePending === true;
1532
1532
  let effectiveCwd = cwd;
1533
- let loggingPool = false;
1534
1533
  let retainWorktree = resumePending;
1535
1534
  const poolAttempts = poolRuntime?.attempts?.slice(-1000) ?? [];
1536
1535
  let worktreeOwner;
@@ -1547,7 +1546,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1547
1546
  catch (err) {
1548
1547
  if (resumePending) {
1549
1548
  sessionLog.warn('[Worktree] Pending worktree recreation failed; retaining work for the next recheck');
1550
- throw new PoolDeferredError();
1549
+ throw new PoolDeferredError('infrastructure', { error: err });
1551
1550
  }
1552
1551
  sessionLog.warn(`[Worktree] Failed to create worktree, falling back to main brain dir: ${err instanceof Error ? err.message : err}`);
1553
1552
  }
@@ -1562,7 +1561,6 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1562
1561
  effectiveSessionId = randomUUID();
1563
1562
  }
1564
1563
  const fetchedPool = poolRuntime ? poolRuntime.pool ?? await fetchRuntimePool(msg) : undefined;
1565
- loggingPool = fetchedPool !== undefined && fetchedPool.kind !== 'no_accounts_configured';
1566
1564
  if (fetchedPool)
1567
1565
  logPoolFetch(sessionLog, fetchedPool);
1568
1566
  const scoped = poolRuntime ? await fetchConfigForSpawn({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }) : undefined;
@@ -1580,8 +1578,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1580
1578
  try {
1581
1579
  await poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts);
1582
1580
  }
1583
- catch {
1584
- throw new PoolDeferredError();
1581
+ catch (error) {
1582
+ throw new PoolDeferredError('infrastructure', { error });
1585
1583
  }
1586
1584
  }
1587
1585
  // Only a quota continuation shares delivery history. A fresh-session
@@ -1638,8 +1636,9 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1638
1636
  if (id && poolRuntime)
1639
1637
  writes.push(poolRuntime.persist(id, { ...accountPin(prepared.account, resolved), effectiveCwd, claudeConfigDir, resumePending: runtime?.resumePending, sawSendMessageCall }, poolAttempts));
1640
1638
  const outcomes = await Promise.allSettled(writes);
1641
- if (outcomes.some(outcome => outcome.status === 'rejected'))
1642
- throw new PoolDeferredError();
1639
+ const failed = outcomes.find((outcome) => outcome.status === 'rejected');
1640
+ if (failed)
1641
+ throw new PoolDeferredError('infrastructure', { error: failed.reason });
1643
1642
  }
1644
1643
  return result;
1645
1644
  };
@@ -1649,8 +1648,10 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1649
1648
  let result = initial;
1650
1649
  while (prepared?.account && (result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller && poolRuntime && runtime) {
1651
1650
  attemptedAccounts.add(prepared.account.accountId);
1652
- if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
1653
- throw new PoolDeferredError();
1651
+ if (poolRuntime.shouldStop())
1652
+ throw new PoolDeferredError('infrastructure', { suppressNotice: true });
1653
+ if (attemptedAccounts.size >= 1000)
1654
+ throw new PoolDeferredError('usage');
1654
1655
  prepared.pool = await fetchRuntimePool(msg);
1655
1656
  logPoolFetch(sessionLog, prepared.pool);
1656
1657
  if (prepared.pool.kind === 'ready')
@@ -1664,11 +1665,18 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1664
1665
  }
1665
1666
  const remaining = prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => !attemptedAccounts.has(a.accountId)) : [];
1666
1667
  const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: remaining.length && prepared.pool.kind === 'ready' ? { ...prepared.pool, accounts: remaining } : prepared.pool, store: prepared.store, now: Date.now(), log: sessionLog, logAllExhausted: remaining.length === 0 });
1667
- if (selected.kind !== 'selected' || attemptedAccounts.has(selected.account.accountId))
1668
- throw await poolDeferredError(prepared.pool, resolved, prepared.store, undefined, remaining.length ? sessionLog : undefined);
1668
+ if (selected.kind !== 'selected' || attemptedAccounts.has(selected.account.accountId)) {
1669
+ const cause = selected.kind === 'deferred' || selected.kind === 'selected' ? 'usage'
1670
+ : selected.kind === 'usage_unavailable' ? 'infrastructure' : 'platform';
1671
+ // Usage eligibility must include previously attempted accounts too.
1672
+ throw await poolDeferredError(cause, prepared.pool, resolved, prepared.store, {
1673
+ log: remaining.length ? sessionLog : undefined,
1674
+ error: selected.kind === 'usage_unavailable' ? selected.error : undefined,
1675
+ });
1676
+ }
1669
1677
  effectiveSessionId = result.attemptedSessionId ?? effectiveSessionId;
1670
1678
  if (!effectiveSessionId)
1671
- throw new PoolDeferredError();
1679
+ throw new PoolDeferredError('infrastructure');
1672
1680
  const fromAccount = prepared.account;
1673
1681
  poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
1674
1682
  prepared.account = selected.account;
@@ -1710,8 +1718,6 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1710
1718
  }
1711
1719
  catch (error) {
1712
1720
  if (error instanceof PoolDeferredError) {
1713
- if (loggingPool)
1714
- poolInfo(sessionLog, `work deferred eligibleAt=${new Date(error.eligibleAt).toISOString()} providerResetAt=${error.resetAt ?? 'unknown'}`);
1715
1721
  retainWorktree = true;
1716
1722
  error.attempts = poolAttempts;
1717
1723
  }
package/dist/poller.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isUsageProbe, handleUsageProbe } from './usage-probe-job.js';
2
2
  import { fetchSubscriptionPool } from './subscription-pool-client.js';
3
3
  import { formatPoolUsageLimitMessage } from './usage-limit-messages.js';
4
- import { logPoolUsageWrite } from './pool-logging.js';
4
+ import { logPoolUsageWrite, poolInfo } from './pool-logging.js';
5
5
  import { emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
6
6
  import { executionDirectory, selectBrainDirectory } from './brain-directory.js';
7
7
  import { config, POLLER_VERSION } from './config.js';
@@ -440,8 +440,8 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
440
440
  try {
441
441
  saveInboxContext(kbPath, inboxMsg);
442
442
  }
443
- catch {
444
- throw new PoolDeferredError();
443
+ catch (error) {
444
+ throw new PoolDeferredError('infrastructure', { error });
445
445
  }
446
446
  const savedRuntime = readInboxRuntime(kbPath, threadId);
447
447
  if (savedRuntime?.filename === message.filename && savedRuntime.pin.workspaceId === queueMessage.teamvibe.workspaceId && (!currentPin || currentPin.accountId === savedRuntime.pin.accountId)) {
@@ -478,7 +478,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
478
478
  persist: async (id, pin, attempts) => {
479
479
  pin = { ...pin, inboxFilename: message.filename };
480
480
  if (!lockToken || await persistSessionId(id, pin) === false)
481
- throw new PoolDeferredError();
481
+ throw new PoolDeferredError('infrastructure');
482
482
  currentSessionId = id;
483
483
  currentPin = pin;
484
484
  saveInboxRuntime(kbPath, threadId, { sessionId: id, pin, filename: message.filename, attempts, quotaDeferEpisodes });
@@ -594,7 +594,7 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
594
594
  context: { threadId, filename: message.filename, iteration, reason: describeFailureReason(lastKilledByPoller, lastExitCode) },
595
595
  });
596
596
  }
597
- if (hasSlackContext && (decision.kind !== 'quota' || notifyQuota)) {
597
+ if (hasSlackContext && !(poolIteration && lastKilledByPoller === 'shutdown') && (decision.kind !== 'quota' || notifyQuota)) {
598
598
  if (decision.kind === 'neutral' || decision.kind === 'quota') {
599
599
  await sendSlackMessage(deliveryMsg, decision.text);
600
600
  }
@@ -608,6 +608,13 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
608
608
  catch (error) {
609
609
  typing?.stop();
610
610
  if (poolIteration && error instanceof PoolDeferredError) {
611
+ poolInfo(sessionLog, `work deferred cause=${error.cause} error=${error.error} eligibleAt=${new Date(error.eligibleAt).toISOString()} providerResetAt=${error.resetAt ?? 'unknown'}`);
612
+ if (error.suppressNotice || shuttingDown) {
613
+ // Any prior marker has expired; retain its notice claim for restart.
614
+ requeueMessage(kbPath, threadId, message.filename);
615
+ deferred = true;
616
+ break;
617
+ }
611
618
  quotaPaused = true;
612
619
  deferred = true;
613
620
  const pause = deferPool(threadId, error.eligibleAt, error.resetAt);
@@ -620,10 +627,11 @@ export async function runInboxLoop(threadId, selectedDirectory, queueMessage, se
620
627
  if (hasSlackContext && pause.notice !== 'delivered') {
621
628
  const reconcile = pause.notice === 'claimed' || pause.notice === 'ambiguous';
622
629
  if (!reconcile && !setPoolNotice(threadId, pause.episodeId, 'claimed'))
623
- throw new PoolDeferredError(error.eligibleAt);
630
+ throw new PoolDeferredError('infrastructure');
624
631
  try {
625
632
  const notice = formatPoolUsageLimitMessage({
626
633
  ...error.notice,
634
+ cause: error.cause,
627
635
  thresholdDeferred: error.notice?.thresholdDeferred && !error.attempts?.some(a => a.quota),
628
636
  resetAt: pause.resetAt ? Date.parse(pause.resetAt) : error.notice?.resetAt,
629
637
  timeZone: config.POLLER_TZ,
@@ -1,3 +1,4 @@
1
+ import type { PoolFetchDiagnostics } from './subscription-pool-client.js';
1
2
  import type { SessionLogger } from './logger.js';
2
3
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
3
4
  import { type AccountIdentity, type AccountUsageState, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
@@ -9,7 +10,10 @@ export declare function poolInfo(log: PoolInfoLogger, message: string): void;
9
10
  export declare function poolLabel(account: {
10
11
  label?: string;
11
12
  }): string;
12
- export declare function logPoolFetch(log: PoolInfoLogger, pool: PoolFetchResult): void;
13
+ export declare function poolErrorClass(error: unknown): string;
14
+ /** Allowlisted class/code only: caught messages and arbitrary names may contain secrets. */
15
+ export declare function poolErrorDiagnostic(error: unknown): string;
16
+ export declare function logPoolFetch(log: PoolInfoLogger, pool: PoolFetchResult & PoolFetchDiagnostics): void;
13
17
  export declare function logPoolSelection<A extends LabeledAccount>(log: PoolInfoLogger, pool: PoolFetchResult<A>, states: Readonly<Record<string, AccountUsageState>>, model: string | undefined, now: number, selected: SelectionOutcome<A>, logAllExhausted?: boolean): void;
14
18
  /** Log the real store promise without changing its rejection or retry behavior. */
15
19
  export declare function logPoolUsageWrite(log: PoolInfoLogger, account: {
@@ -11,13 +11,36 @@ export function poolLabel(account) {
11
11
  const label = account.label;
12
12
  return JSON.stringify(label && label.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(label) ? label : 'unlabeled');
13
13
  }
14
+ const POOL_ERROR_CLASSES = new Set([
15
+ 'Error', 'TypeError', 'RangeError', 'AbortError', 'TimeoutError', 'SubscriptionUsageStoreError',
16
+ 'ConditionalCheckFailedException', 'AccessDeniedException', 'ExpiredTokenException', 'CredentialsProviderError',
17
+ 'ThrottlingException', 'ProvisionedThroughputExceededException', 'ValidationException', 'ResourceNotFoundException',
18
+ ]);
19
+ export function poolErrorClass(error) {
20
+ return error instanceof Error && POOL_ERROR_CLASSES.has(error.name) ? error.name : 'UnknownError';
21
+ }
22
+ /** Allowlisted class/code only: caught messages and arbitrary names may contain secrets. */
23
+ export function poolErrorDiagnostic(error) {
24
+ if (error === undefined)
25
+ return 'none';
26
+ const record = (value) => value && typeof value === 'object' ? value : {};
27
+ const outer = record(error);
28
+ const code = record(outer['cause'])['code'] ?? outer['code'];
29
+ if (typeof code === 'string' && (POOL_ERROR_CLASSES.has(code) || [
30
+ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_ABORTED',
31
+ 'ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'ENETUNREACH', 'EHOSTUNREACH',
32
+ 'ENOENT', 'EACCES', 'EPERM', 'ENOSPC', 'ENOTDIR', 'EIO',
33
+ ].includes(code)))
34
+ return code;
35
+ return poolErrorClass(error);
36
+ }
14
37
  export function logPoolFetch(log, pool) {
15
38
  if (pool.kind === 'no_accounts_configured')
16
39
  return;
17
40
  // The API exposes usable members only; hidden disabled labels are unavailable.
18
41
  const enabled = pool.kind === 'ready' ? pool.accounts.filter(a => a.enabled).length : pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts' ? 0 : 'unknown';
19
42
  const configured = pool.kind === 'ready' || pool.kind === 'fetch_unavailable' && pool.reason === 'no_usable_accounts' ? 'true' : 'unknown';
20
- poolInfo(log, `fetch poolConfigured=${configured} enabled=${enabled} usable=${enabled} result=${pool.kind}${pool.kind === 'fetch_unavailable' ? ` reason=${pool.reason}` : ''}`);
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}`);
21
44
  }
22
45
  export function logPoolSelection(log, pool, states, model, now, selected, logAllExhausted = true) {
23
46
  if (pool.kind !== 'ready')
@@ -29,7 +29,11 @@ export interface PoolClientOptions {
29
29
  }
30
30
  /** Deadline covers response headers AND body, even for an uncooperative adapter. */
31
31
  export declare function boundedRequest<T>(work: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal, timeoutMs?: number): Promise<T>;
32
+ export interface PoolFetchDiagnostics {
33
+ attempts?: number;
34
+ cause?: string;
35
+ }
32
36
  /** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
33
37
  export declare function readSubscriptionJson(response: Response): Promise<unknown>;
34
38
  /** No global config, logging, workspace fallback or credential cache. */
35
- export declare function fetchSubscriptionPool(input: PoolFetchInput, options: PoolClientOptions): Promise<PoolFetchResult<SubscriptionAccount>>;
39
+ export declare function fetchSubscriptionPool(input: PoolFetchInput, options: PoolClientOptions): Promise<PoolFetchResult<SubscriptionAccount> & PoolFetchDiagnostics>;
@@ -1,3 +1,4 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
1
2
  import { ACCOUNT_PROBE_FAILURE_REASONS } from './usage-probe-contract.js';
2
3
  import { record } from './subscription-selection.js';
3
4
  /** Unwrap only at the optional Claude probe or future child-spawn boundary. */
@@ -39,6 +40,17 @@ export async function boundedRequest(work, signal, timeoutMs = 15_000) {
39
40
  controller.signal.removeEventListener('abort', onAbort);
40
41
  }
41
42
  }
43
+ /** Only allow known transport codes/classes out; never expose error messages. */
44
+ function networkCause(error) {
45
+ const outer = record(error);
46
+ const code = record(outer['cause'])['code'] ?? outer['code'];
47
+ if (typeof code === 'string' && [
48
+ 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_ABORTED',
49
+ 'ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'ENETUNREACH', 'EHOSTUNREACH',
50
+ ].includes(code))
51
+ return code;
52
+ return error instanceof Error && ['Error', 'TypeError', 'AbortError', 'TimeoutError'].includes(error.name) ? error.name : 'UnknownError';
53
+ }
42
54
  /** Bounded, ephemeral raw body. Neither it nor caught errors leave this module. */
43
55
  export async function readSubscriptionJson(response) {
44
56
  const reader = response.body?.getReader();
@@ -88,6 +100,21 @@ export async function fetchSubscriptionPool(input, options) {
88
100
  catch {
89
101
  return { kind: 'fetch_unavailable', reason: 'configuration' };
90
102
  }
103
+ for (let attempts = 1;; attempts++) {
104
+ const result = await fetchPoolAttempt(url, input, options);
105
+ const retryable = result.kind === 'fetch_unavailable' && (result.reason === 'network' ||
106
+ result.reason === 'http' && (result.status === 429 || (result.status !== undefined && result.status >= 500 && result.status <= 599)));
107
+ if (!retryable || attempts >= 3)
108
+ return attempts > 1 ? { ...result, attempts } : result;
109
+ try {
110
+ await delay(attempts === 1 ? 500 : 2_000, undefined, { signal: options.signal });
111
+ }
112
+ catch {
113
+ return { kind: 'fetch_unavailable', reason: 'cancelled', attempts };
114
+ }
115
+ }
116
+ }
117
+ async function fetchPoolAttempt(url, input, options) {
91
118
  try {
92
119
  return await boundedRequest(async (signal) => {
93
120
  const response = await (options.fetch ?? fetch)(url, {
@@ -144,7 +171,7 @@ export async function fetchSubscriptionPool(input, options) {
144
171
  return { kind: 'unknown_pool_state' };
145
172
  }, options.signal, options.timeoutMs);
146
173
  }
147
- catch {
148
- return { kind: 'fetch_unavailable', reason: options.signal?.aborted ? 'cancelled' : 'network' };
174
+ catch (error) {
175
+ return { kind: 'fetch_unavailable', reason: options.signal?.aborted ? 'cancelled' : 'network', cause: networkCause(error) };
149
176
  }
150
177
  }
@@ -5,13 +5,16 @@ import { type SubscriptionUsageStore } from './subscription-usage-store.js';
5
5
  export type ResolvedPoolModel = ReturnType<typeof resolveModel>;
6
6
  export type ResolvedPoolSelection<A extends AccountIdentity> = (SelectionOutcome<A> | {
7
7
  kind: 'usage_unavailable';
8
+ error: unknown;
8
9
  }) & ResolvedPoolModel;
9
10
  /**
10
11
  * #405 integration seam: resolveModel ONCE before calling, then reuse the
11
12
  * returned model/modelSource for --model. No fetch, probe, spawn or env fallback
12
13
  * occurs here. Only no_accounts_configured permits the legacy env credential.
13
14
  */
14
- export declare function selectPoolForResolvedModel<A extends AccountIdentity>(resolvedModel: ResolvedPoolModel, input: {
15
+ export declare function selectPoolForResolvedModel<A extends AccountIdentity & {
16
+ label?: string;
17
+ }>(resolvedModel: ResolvedPoolModel, input: {
15
18
  workspaceId: string;
16
19
  pool: PoolFetchResult<A>;
17
20
  store: SubscriptionUsageStore;
@@ -1,4 +1,5 @@
1
- import { logPoolSelection } from './pool-logging.js';
1
+ import { logger } from './logger.js';
2
+ import { logPoolSelection, poolInfo, poolLabel, poolErrorClass } from './pool-logging.js';
2
3
  import { selectSubscriptionAccount } from './subscription-selection.js';
3
4
  import { usageStateForSelection } from './subscription-usage-store.js';
4
5
  /**
@@ -17,11 +18,17 @@ export async function selectPoolForResolvedModel(resolvedModel, input) {
17
18
  const states = Object.create(null);
18
19
  try {
19
20
  await Promise.all(input.pool.accounts.map(async (account) => {
20
- states[account.accountId] = usageStateForSelection(await input.store.read(account), input.now);
21
+ try {
22
+ states[account.accountId] = usageStateForSelection(await input.store.read(account), input.now);
23
+ }
24
+ catch (error) {
25
+ poolInfo(input.log ?? logger, `usage read account=${poolLabel(account)} result=failed errorClass=${poolErrorClass(error)}`);
26
+ throw error;
27
+ }
21
28
  }));
22
29
  }
23
- catch {
24
- return { kind: 'usage_unavailable', ...resolvedModel };
30
+ catch (error) {
31
+ return { kind: 'usage_unavailable', error, ...resolvedModel };
25
32
  }
26
33
  const selected = selectSubscriptionAccount({ pool: input.pool, states, model: resolvedModel.model, now: input.now, currentAccountId: input.currentAccountId });
27
34
  if (input.log)
@@ -1,10 +1,10 @@
1
1
  import { type PoolInfoLogger } from './pool-logging.js';
2
- import { type SubscriptionAccount } from './subscription-pool-client.js';
2
+ import { type PoolFetchDiagnostics, type SubscriptionAccount } from './subscription-pool-client.js';
3
3
  import { type ResolvedPoolModel } from './subscription-pool.js';
4
4
  import { FileSubscriptionUsageStore, type SubscriptionUsageStore } from './subscription-usage-store.js';
5
5
  import { type PoolFetchResult, type HeadroomScore } from './subscription-selection.js';
6
6
  import type { SubscriptionPin, TeamVibeQueueMessage, SessionMetrics } from './types.js';
7
- import type { PoolUsageLimitNotice } from './usage-limit-messages.js';
7
+ import type { PoolUsageLimitNotice, PoolDeferCause } from './usage-limit-messages.js';
8
8
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
9
9
  import type { ClaudeQuotaFailure } from './claude-quota.js';
10
10
  import type { KilledByPoller } from './classify-failed-iteration.js';
@@ -23,12 +23,21 @@ export interface PoolAttempt extends Partial<HeadroomScore> {
23
23
  quota?: ClaudeQuotaFailure;
24
24
  metrics?: SessionMetrics;
25
25
  }
26
+ export declare const POOL_UNAVAILABLE_RECHECK_MS = 60000;
26
27
  export declare class PoolDeferredError extends Error {
27
- readonly eligibleAt: number;
28
- readonly resetAt?: string | undefined;
28
+ readonly cause: PoolDeferCause;
29
29
  attempts?: readonly PoolAttempt[];
30
- notice?: PoolUsageLimitNotice;
31
- constructor(eligibleAt?: number, resetAt?: string | undefined);
30
+ notice?: Omit<PoolUsageLimitNotice, 'cause'>;
31
+ readonly eligibleAt: number;
32
+ readonly resetAt?: string;
33
+ readonly suppressNotice: boolean;
34
+ readonly error: string;
35
+ constructor(cause: PoolDeferCause, options?: {
36
+ eligibleAt?: number;
37
+ resetAt?: string;
38
+ error?: unknown;
39
+ suppressNotice?: boolean;
40
+ });
32
41
  }
33
42
  export interface PoolRuntimeOptions {
34
43
  pin?: SubscriptionPin;
@@ -41,11 +50,28 @@ export interface PoolRuntimeOptions {
41
50
  shouldStop: () => boolean;
42
51
  }
43
52
  export declare function subscriptionUsageStore(): FileSubscriptionUsageStore;
44
- export declare function fetchRuntimePool(msg: TeamVibeQueueMessage): Promise<PoolFetchResult<SubscriptionAccount>>;
53
+ export declare function fetchRuntimePool(msg: TeamVibeQueueMessage): Promise<PoolFetchResult<SubscriptionAccount> & PoolFetchDiagnostics>;
45
54
  export declare function preparePool(msg: TeamVibeQueueMessage, resolved: ResolvedPoolModel, pin?: SubscriptionPin, fetched?: PoolFetchResult<SubscriptionAccount>, log?: PoolInfoLogger): Promise<{
46
55
  account: SubscriptionAccount;
47
56
  store: FileSubscriptionUsageStore;
48
- pool: PoolFetchResult<SubscriptionAccount>;
57
+ pool: {
58
+ kind: "probe_account_unavailable";
59
+ reason: import("./usage-probe-contract.js").AccountProbeFailureReason;
60
+ } | {
61
+ kind: "no_accounts_configured";
62
+ } | {
63
+ kind: "unknown_pool_state";
64
+ } | {
65
+ kind: "fetch_unauthorized";
66
+ status: 401 | 403;
67
+ } | {
68
+ kind: "fetch_unavailable";
69
+ reason: "configuration" | "http" | "network" | "invalid_response" | "cancelled" | "no_usable_accounts";
70
+ status?: number;
71
+ } | {
72
+ kind: "ready";
73
+ accounts: readonly SubscriptionAccount[];
74
+ };
49
75
  selection: ({
50
76
  kind: "selected";
51
77
  account: SubscriptionAccount;
@@ -66,10 +92,31 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
66
92
  } | {
67
93
  account: undefined;
68
94
  store: FileSubscriptionUsageStore;
69
- pool: PoolFetchResult<SubscriptionAccount>;
95
+ pool: {
96
+ kind: "probe_account_unavailable";
97
+ reason: import("./usage-probe-contract.js").AccountProbeFailureReason;
98
+ } | {
99
+ kind: "no_accounts_configured";
100
+ } | {
101
+ kind: "unknown_pool_state";
102
+ } | {
103
+ kind: "fetch_unauthorized";
104
+ status: 401 | 403;
105
+ } | {
106
+ kind: "fetch_unavailable";
107
+ reason: "configuration" | "http" | "network" | "invalid_response" | "cancelled" | "no_usable_accounts";
108
+ status?: number;
109
+ } | {
110
+ kind: "ready";
111
+ accounts: readonly SubscriptionAccount[];
112
+ };
70
113
  selection: undefined;
71
114
  }>;
72
115
  export declare function accountPin(account: SubscriptionAccount, resolved: ResolvedPoolModel): SubscriptionPin;
73
116
  export declare function validateAccount(account: SubscriptionAccount, resolved: ResolvedPoolModel): Promise<void>;
74
117
  /** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
75
- export declare function poolDeferredError(pool: PoolFetchResult<SubscriptionAccount>, resolved: ResolvedPoolModel, store: SubscriptionUsageStore, eligibleAt?: number, log?: PoolInfoLogger): Promise<PoolDeferredError>;
118
+ export declare function poolDeferredError(cause: PoolDeferCause, pool: PoolFetchResult<SubscriptionAccount> & PoolFetchDiagnostics, resolved: ResolvedPoolModel, store: SubscriptionUsageStore, options?: {
119
+ eligibleAt?: number;
120
+ log?: PoolInfoLogger;
121
+ error?: unknown;
122
+ }): Promise<PoolDeferredError>;
@@ -1,4 +1,4 @@
1
- import { poolInfo, poolLabel } from './pool-logging.js';
1
+ import { poolInfo, poolLabel, poolErrorDiagnostic } from './pool-logging.js';
2
2
  import { emitCandidateUsage } from './pool-telemetry.js';
3
3
  import { join } from 'node:path';
4
4
  import { config } from './config.js';
@@ -6,16 +6,23 @@ import { getPollerId } from './auth-provider.js';
6
6
  import { fetchSubscriptionPool } from './subscription-pool-client.js';
7
7
  import { selectPoolForResolvedModel } from './subscription-pool.js';
8
8
  import { FileSubscriptionUsageStore, usageStateForSelection } from './subscription-usage-store.js';
9
- import { normalizePassiveUsage, selectSubscriptionAccount, PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
9
+ import { normalizePassiveUsage, selectSubscriptionAccount, usageWindowExpiresAt, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
10
+ export const POOL_UNAVAILABLE_RECHECK_MS = 60_000;
10
11
  export class PoolDeferredError extends Error {
11
- eligibleAt;
12
- resetAt;
12
+ cause;
13
13
  attempts;
14
14
  notice;
15
- constructor(eligibleAt = Date.now() + POOL_RECHECK_MS, resetAt) {
15
+ eligibleAt;
16
+ resetAt;
17
+ suppressNotice;
18
+ error;
19
+ constructor(cause, options = {}) {
16
20
  super('Subscription pool unavailable; work retained');
17
- this.eligibleAt = eligibleAt;
18
- this.resetAt = resetAt;
21
+ this.cause = cause;
22
+ this.eligibleAt = cause === 'usage' ? options.eligibleAt ?? Date.now() + POOL_RECHECK_MS : Date.now() + POOL_UNAVAILABLE_RECHECK_MS;
23
+ this.resetAt = cause === 'usage' ? options.resetAt : undefined;
24
+ this.suppressNotice = options.suppressNotice === true;
25
+ this.error = options.error instanceof PoolDeferredError ? options.error.error : poolErrorDiagnostic(options.error);
19
26
  }
20
27
  }
21
28
  export function subscriptionUsageStore() {
@@ -53,15 +60,25 @@ export async function preparePool(msg, resolved, pin, fetched, log) {
53
60
  }
54
61
  if (selected.kind === 'no_accounts_configured')
55
62
  return { account: undefined, store, pool, selection: undefined };
56
- throw await poolDeferredError(pool, resolved, store, selected.kind === 'deferred' ? selected.eligibleAt : undefined);
63
+ const cause = selected.kind === 'deferred' ? 'usage' : selected.kind === 'usage_unavailable' ? 'infrastructure' : 'platform';
64
+ throw await poolDeferredError(cause, pool, resolved, store, {
65
+ eligibleAt: selected.kind === 'deferred' ? selected.eligibleAt : undefined,
66
+ log, error: selected.kind === 'usage_unavailable' ? selected.error : undefined,
67
+ });
57
68
  }
58
69
  export function accountPin(account, resolved) {
59
70
  return { workspaceId: account.workspaceId, accountId: account.accountId, credentialVersion: account.credentialVersion, ...resolved };
60
71
  }
61
72
  export async function validateAccount(account, resolved) {
62
- const selected = await selectPoolForResolvedModel(resolved, { workspaceId: account.workspaceId, pool: { kind: 'ready', accounts: [account] }, store: subscriptionUsageStore(), now: Date.now() });
63
- if (selected.kind !== 'selected')
64
- throw new PoolDeferredError(selected.kind === 'deferred' ? selected.eligibleAt : undefined);
73
+ const store = subscriptionUsageStore();
74
+ const pool = { kind: 'ready', accounts: [account] };
75
+ const selected = await selectPoolForResolvedModel(resolved, { workspaceId: account.workspaceId, pool, store, now: Date.now() });
76
+ if (selected.kind !== 'selected') {
77
+ throw await poolDeferredError(selected.kind === 'deferred' ? 'usage' : 'infrastructure', pool, resolved, store, {
78
+ eligibleAt: selected.kind === 'deferred' ? selected.eligibleAt : undefined,
79
+ error: selected.kind === 'usage_unavailable' ? selected.error : undefined,
80
+ });
81
+ }
65
82
  }
66
83
  /** Message-only evidence; never changes selection, eligibility or durable pause state. */
67
84
  async function poolUsageNotice(pool, resolved, store) {
@@ -72,9 +89,8 @@ async function poolUsageNotice(pool, resolved, store) {
72
89
  const accounts = await Promise.all(pool.accounts.map(async (account) => {
73
90
  const state = usageStateForSelection(await store.read(account), now);
74
91
  const usage = state.usage;
75
- const ttl = usage?.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS;
76
- const windows = usage && usage.observedAt <= now && now < usage.observedAt + ttl
77
- ? usage.windows.filter(w => w.resetAt === undefined || w.resetAt > now) : [];
92
+ const windows = usage && usage.observedAt <= now
93
+ ? usage.windows.filter(w => now < usageWindowExpiresAt(usage, w)) : [];
78
94
  const rejection = selectSubscriptionAccount({
79
95
  pool: { kind: 'ready', accounts: [account] },
80
96
  states: { [account.accountId]: { usage: usage ? { ...usage, windows: usage.windows.filter(w => w.status === 'rejected') } : undefined } },
@@ -100,17 +116,19 @@ async function poolUsageNotice(pool, resolved, store) {
100
116
  }
101
117
  }
102
118
  /** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
103
- export async function poolDeferredError(pool, resolved, store, eligibleAt, log) {
119
+ export async function poolDeferredError(cause, pool, resolved, store, options = {}) {
120
+ const { log } = options;
121
+ let { eligibleAt } = options;
122
+ if (cause !== 'usage')
123
+ return new PoolDeferredError(cause, { error: options.error ?? (pool.kind === 'fetch_unavailable' && pool.cause ? { code: pool.cause } : undefined) });
104
124
  const notice = await poolUsageNotice(pool, resolved, store);
105
- let usageDeferred = eligibleAt !== undefined;
106
125
  const deferredError = (resetAt) => {
107
- const error = new PoolDeferredError(eligibleAt, resetAt);
108
- error.notice = { ...notice, thresholdDeferred: usageDeferred && notice.thresholdDeferred, resetAt: resetAt ? Date.parse(resetAt) : notice.resetAt };
126
+ const error = new PoolDeferredError('usage', { eligibleAt, resetAt });
127
+ error.notice = { ...notice, resetAt: resetAt ? Date.parse(resetAt) : notice.resetAt };
109
128
  return error;
110
129
  };
111
130
  if (eligibleAt === undefined) {
112
131
  const selected = await selectPoolForResolvedModel(resolved, { workspaceId: pool.kind === 'ready' ? pool.accounts[0]?.workspaceId ?? '' : '', pool, store, now: Date.now(), log });
113
- usageDeferred = selected.kind === 'deferred';
114
132
  eligibleAt = selected.kind === 'deferred' ? selected.eligibleAt : Date.now() + POOL_RECHECK_MS;
115
133
  }
116
134
  if (pool.kind === 'ready') {
@@ -1,7 +1,7 @@
1
1
  import type { AccountProbeFailureReason } from './usage-probe-contract.js';
2
2
  import type { ClaudeQuotaFailure } from './claude-quota.js';
3
3
  export declare const USAGE_THRESHOLD_PERCENT = 90;
4
- /** Passive snapshots are useful for five minutes, then become unknown. */
4
+ /** Fallback freshness for passive windows without a reset time. */
5
5
  export declare const PASSIVE_USAGE_TTL_MS: number;
6
6
  export declare const PROBE_USAGE_TTL_MS = 60000;
7
7
  export declare const POOL_RECHECK_MS: number;
@@ -49,6 +49,8 @@ export interface UsageObservation {
49
49
  observedAt: number;
50
50
  windows: readonly UsageWindow[];
51
51
  }
52
+ /** Passive percentages remain lower bounds until reset; probes retain their short TTL. */
53
+ export declare function usageWindowExpiresAt(usage: UsageObservation, window: UsageWindow): number;
52
54
  /** Latest measurements plus durable rejections; omission or success never clears a hold. */
53
55
  export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
54
56
  export interface ReactiveExhaustion {
@@ -1,10 +1,15 @@
1
1
  export const USAGE_THRESHOLD_PERCENT = 90;
2
- /** Passive snapshots are useful for five minutes, then become unknown. */
2
+ /** Fallback freshness for passive windows without a reset time. */
3
3
  export const PASSIVE_USAGE_TTL_MS = 5 * 60_000;
4
4
  export const PROBE_USAGE_TTL_MS = 60_000;
5
5
  export const POOL_RECHECK_MS = 15 * 60_000;
6
6
  /** Safety hold from the rejection observation when the provider gives no reset. */
7
7
  export const REJECTED_BLOCK_MS = 15 * 60_000;
8
+ /** Passive percentages remain lower bounds until reset; probes retain their short TTL. */
9
+ export function usageWindowExpiresAt(usage, window) {
10
+ const ttlExpiry = usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS);
11
+ return usage.source === 'passive' ? window.resetAt ?? ttlExpiry : Math.min(window.resetAt ?? ttlExpiry, ttlExpiry);
12
+ }
8
13
  /** Latest measurements plus durable rejections; omission or success never clears a hold. */
9
14
  export function mergeUsageObservations(observations, now) {
10
15
  let latest;
@@ -200,13 +205,11 @@ export function selectSubscriptionAccount(input) {
200
205
  blockers.push(until);
201
206
  }
202
207
  }
203
- const expiry = usage ? usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS) : 0;
204
- const fresh = usage && usage.observedAt <= input.now && input.now < expiry;
205
- const windows = fresh ? usage.windows.filter(w => w.status !== 'rejected' && (w.observedAt ?? usage.observedAt) === usage.observedAt && (w.source ?? usage.source) === usage.source && applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
208
+ 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)) : [];
206
209
  const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && !family(input.model)));
207
210
  for (const w of windows) {
208
211
  if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
209
- blockers.push(Math.min(w.resetAt ?? expiry, expiry));
212
+ blockers.push(usageWindowExpiresAt(usage, w));
210
213
  }
211
214
  if (blockers.length) {
212
215
  deadlines.push(Math.max(...blockers));
@@ -13,7 +13,9 @@ export interface UsageLimitAccount {
13
13
  }[];
14
14
  rejected?: boolean;
15
15
  }
16
+ export type PoolDeferCause = 'usage' | 'platform' | 'infrastructure';
16
17
  export interface PoolUsageLimitNotice extends UsageResetTiming {
18
+ cause: PoolDeferCause;
17
19
  thresholdDeferred?: boolean;
18
20
  accounts?: readonly UsageLimitAccount[];
19
21
  }
@@ -1,3 +1,4 @@
1
+ import { USAGE_THRESHOLD_PERCENT } from './subscription-selection.js';
1
2
  /** Owner-facing account policy location. */
2
3
  export const FULL_QUOTA_SETTINGS_PATH = 'Connections (account menu → Use until exhausted)';
3
4
  export function formatUsageReset(input) {
@@ -24,6 +25,12 @@ export function formatUsageReset(input) {
24
25
  return `${time} (in ${relative})`;
25
26
  }
26
27
  export function formatPoolUsageLimitMessage(input) {
28
+ if (input.cause === 'infrastructure') {
29
+ return "⏳ Something went wrong while starting your session. Your message is saved and I'll retry in about a minute.";
30
+ }
31
+ if (input.cause === 'platform') {
32
+ return "⏳ I couldn't reach the TeamVibe platform to pick a Claude account. Your message is saved and I'll retry in about a minute.";
33
+ }
27
34
  const accounts = input.accounts ?? [];
28
35
  const headroom = accounts.filter(account => !account.useFullQuota && !account.rejected).map(account => {
29
36
  const percentages = account.windows.map(w => w.percent).filter((p) => p !== undefined && Number.isFinite(p) && p >= 0 && p <= 100);
@@ -34,7 +41,7 @@ export function formatPoolUsageLimitMessage(input) {
34
41
  const reset = formatUsageReset(input);
35
42
  if (threshold) {
36
43
  const labels = headroom.filter(a => a.label && a.remaining > 0).map(a => `${a.label} ${Number(a.remaining.toFixed(2))} %`).join(', ');
37
- return `⏳ All Claude accounts in this workspace are above 90 % of their usage limit, so I'm waiting for the next reset${reset ? ` at ${reset}` : ''}. Your message is saved and will run automatically.\n💡 There is still some capacity left${labels ? ` (${labels})` : ''}. A workspace owner can allow the full quota per account in ${FULL_QUOTA_SETTINGS_PATH}.`;
44
+ return `⏳ All Claude accounts in this workspace are above ${USAGE_THRESHOLD_PERCENT} % of their usage limit, so I'm waiting for the next reset${reset ? ` at ${reset}` : ''}. Your message is saved and will run automatically.\n💡 There is still some capacity left${labels ? ` (${labels})` : ''}. A workspace owner can allow the full quota per account in ${FULL_QUOTA_SETTINGS_PATH}.`;
38
45
  }
39
46
  return `⏳ All Claude accounts in this workspace have hit their usage limit. Your message is saved and will run automatically after ${reset ? `the earliest reset at ${reset}` : 'the next reset'}.`;
40
47
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.4",
9
+ "version": "0.8.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.4",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {