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

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.
@@ -1,5 +1,5 @@
1
1
  import { logPoolFetch, logPoolUsageWrite, poolInfo, poolLabel } from './pool-logging.js';
2
- import { accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
2
+ import { emitPoolSelection, accountUsageTelemetry, emitCandidateUsage, poolRunTelemetry } from './pool-telemetry.js';
3
3
  import { spawn } from 'child_process';
4
4
  import { join } from 'path';
5
5
  import { readFileSync, existsSync } from 'fs';
@@ -1046,6 +1046,8 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1046
1046
  activeProcessesByThread.set(msg.thread_id, proc);
1047
1047
  if (runtime?.account)
1048
1048
  activeSubscriptionAccounts.set(proc, runtime.account);
1049
+ if (proc.pid !== undefined)
1050
+ runtime?.onSpawn?.();
1049
1051
  for (const chunk of spawnStdinPlan.stdinChunks) {
1050
1052
  proc.stdin?.write(chunk);
1051
1053
  }
@@ -1579,7 +1581,18 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1579
1581
  const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog, { cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent, log: line => sessionLog.info(line) }) : undefined;
1580
1582
  const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
1581
1583
  ? scoped?.modelNormalizedFrom : undefined;
1582
- const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
1584
+ const runtime = poolRuntime ? {
1585
+ resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account,
1586
+ shouldStop: poolRuntime.shouldStop,
1587
+ resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true,
1588
+ onSpawn: () => {
1589
+ if (prepared?.selection) {
1590
+ emitPoolSelection(msg, prepared.selection);
1591
+ // Retries report each launch, but release the original pin only once.
1592
+ delete prepared.selection.switch;
1593
+ }
1594
+ },
1595
+ } : undefined;
1583
1596
  let sawSendMessageCall = poolRuntime?.pin?.resumePending === true && poolRuntime.pin.sawSendMessageCall === true;
1584
1597
  const attempt = async (id, first) => {
1585
1598
  if (prepared?.account && id && poolRuntime) {
@@ -1689,6 +1702,12 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1689
1702
  abortLocalProbe(selected.account);
1690
1703
  poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
1691
1704
  prepared.account = selected.account;
1705
+ selected.candidates = [
1706
+ ...(selected.candidates ?? []).filter(c => !attemptedAccounts.has(c.accountId)),
1707
+ ...(prepared.pool.kind === 'ready' ? prepared.pool.accounts.filter(a => attemptedAccounts.has(a.accountId)).map(a => ({ accountId: a.accountId, accountLabel: JSON.parse(poolLabel(a)), outcome: 'skipped', reason: 'already exhausted in this session' })) : []),
1708
+ ];
1709
+ if (prepared.pool.kind === 'ready' && prepared.pool.accounts.some(a => a.accountId === fromAccount.accountId))
1710
+ selected.switch = { fromAccountId: fromAccount.accountId, reason: 'reactive quota' };
1692
1711
  prepared.selection = selected;
1693
1712
  runtime.account = selected.account;
1694
1713
  runtime.resumePending = true;
@@ -14,6 +14,18 @@ export declare function poolErrorClass(error: unknown): string;
14
14
  /** Allowlisted class/code only: caught messages and arbitrary names may contain secrets. */
15
15
  export declare function poolErrorDiagnostic(error: unknown): string;
16
16
  export declare function logPoolFetch(log: PoolInfoLogger, pool: PoolFetchResult & PoolFetchDiagnostics): void;
17
+ export interface PoolSelectionCandidate {
18
+ accountId: string;
19
+ accountLabel: string;
20
+ outcome: 'selected' | 'skipped';
21
+ reason: string;
22
+ score?: number;
23
+ headroom?: number;
24
+ rateWindow?: Pick<UsageWindow, 'window' | 'scope'>;
25
+ eligibleAt?: number;
26
+ }
27
+ /** The log and transport share a single explanation of the same selection snapshot. */
28
+ export declare function poolSelectionCandidates<A extends LabeledAccount>(pool: PoolFetchResult<A>, states: Readonly<Record<string, AccountUsageState>>, model: string | undefined, now: number, selected: SelectionOutcome<A>): PoolSelectionCandidate[];
17
29
  export declare function logPoolSelection<A extends LabeledAccount>(log: PoolInfoLogger, pool: PoolFetchResult<A>, states: Readonly<Record<string, AccountUsageState>>, model: string | undefined, now: number, selected: SelectionOutcome<A>, logAllExhausted?: boolean): void;
18
30
  export declare function formatPoolUsageWindows(windows: readonly UsageWindow[], includeScope?: boolean): string;
19
31
  /** Log the real store promise without changing its rejection or retry behavior. */
@@ -42,23 +42,36 @@ 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
- export function logPoolSelection(log, pool, states, model, now, selected, logAllExhausted = true) {
45
+ /** The log and transport share a single explanation of the same selection snapshot. */
46
+ export function poolSelectionCandidates(pool, states, model, now, selected) {
46
47
  if (pool.kind !== 'ready')
47
- return;
48
- for (const account of pool.accounts) {
49
- if (!account.enabled) {
50
- poolInfo(log, `skipped account=${poolLabel(account)} reason=disabled`);
51
- continue;
52
- }
48
+ return [];
49
+ return pool.accounts.map(account => {
50
+ const base = { accountId: account.accountId, accountLabel: JSON.parse(poolLabel(account)), outcome: 'skipped' };
51
+ if (!account.enabled)
52
+ return { ...base, reason: 'disabled' };
53
53
  const candidate = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states, model, now });
54
54
  if (candidate.kind === 'deferred') {
55
55
  const usageOnly = selectSubscriptionAccount({ pool: { ...pool, accounts: [account] }, states: { [account.accountId]: { usage: states[account.accountId]?.usage } }, model, now });
56
- poolInfo(log, `skipped account=${poolLabel(account)} reason=${usageOnly.kind === 'deferred' ? `usage >=${account.useFullQuota ? 100 : 90}%` : 'reactive quota'} eligibleAt=${new Date(candidate.eligibleAt).toISOString()}`);
57
- }
58
- else if (candidate.kind === 'selected' && selected.kind === 'selected' && account.accountId !== selected.account.accountId) {
59
- const reason = candidate.provenance === 'unknown' && selected.provenance === 'known' ? 'known observation preferred' : candidate.provenance === 'known' && selected.provenance === 'known' && candidate.score < selected.score ? 'lower headroom per hour' : 'deterministic account order';
60
- poolInfo(log, `skipped account=${poolLabel(account)} reason=${reason}`);
56
+ return { ...base, reason: usageOnly.kind === 'deferred' ? `usage >=${account.useFullQuota ? 100 : 90}%` : 'reactive quota', eligibleAt: candidate.eligibleAt };
61
57
  }
58
+ 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 } } : {};
60
+ if (selected.kind === 'selected' && account.accountId === selected.account.accountId)
61
+ return { ...base, ...metrics, outcome: 'selected', reason: 'selected' };
62
+ 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'
64
+ : 'unavailable';
65
+ return { ...base, ...metrics, reason };
66
+ });
67
+ }
68
+ export function logPoolSelection(log, pool, states, model, now, selected, logAllExhausted = true) {
69
+ if (pool.kind !== 'ready')
70
+ return;
71
+ for (const candidate of poolSelectionCandidates(pool, states, model, now, selected)) {
72
+ if (candidate.outcome !== 'skipped' || candidate.reason === 'unavailable')
73
+ continue;
74
+ poolInfo(log, `skipped account=${JSON.stringify(candidate.accountLabel)} reason=${candidate.reason}${candidate.eligibleAt === undefined ? '' : ` eligibleAt=${new Date(candidate.eligibleAt).toISOString()}`}`);
62
75
  }
63
76
  if (selected.kind === 'deferred' && logAllExhausted) {
64
77
  // Ask the same policy about each observation so model scope, freshness and
@@ -60,3 +60,5 @@ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quota
60
60
  model?: string | undefined;
61
61
  observedAt?: number | undefined;
62
62
  };
63
+ /** Labels and explicit decision metrics only; never spread account or credential objects. */
64
+ export declare function emitPoolSelection(msg: TeamVibeQueueMessage, selection: import('./subscription-pool.js').ResolvedPoolSelection<SubscriptionAccount>): void;
@@ -1,5 +1,5 @@
1
1
  import { emitEvent } from './event-queue.js';
2
- import { reactiveEligibleAt } from './subscription-selection.js';
2
+ import { family, reactiveEligibleAt } from './subscription-selection.js';
3
3
  import { usageStateForSelection, safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
4
4
  function safeText(value) {
5
5
  return value && value.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(value)
@@ -123,3 +123,22 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
123
123
  attempts: safeAttempts,
124
124
  };
125
125
  }
126
+ // Keep ordering across retries and intervening threads even within one clock tick.
127
+ let lastSelectionAt = 0;
128
+ /** Labels and explicit decision metrics only; never spread account or credential objects. */
129
+ export function emitPoolSelection(msg, selection) {
130
+ if (selection.kind !== 'selected')
131
+ return;
132
+ lastSelectionAt = Math.max(Date.now(), lastSelectionAt + 1);
133
+ emitEvent('pool.selection', msg.teamvibe.channelId, {
134
+ workspaceId: msg.teamvibe.workspaceId,
135
+ threadId: msg.thread_id,
136
+ model: safeText(selection.model) ?? 'CLI default',
137
+ modelFamily: family(selection.model) ?? 'default',
138
+ selectedAccountId: selection.account.accountId,
139
+ selectedAccountLabel: safeText(selection.account.label) ?? 'unlabeled',
140
+ decidedAt: lastSelectionAt,
141
+ candidates: selection.candidates ?? [],
142
+ ...(selection.switch ? { switch: selection.switch } : {}),
143
+ });
144
+ }
@@ -1,4 +1,4 @@
1
- import { type PoolInfoLogger } from './pool-logging.js';
1
+ import { type PoolSelectionCandidate, type PoolInfoLogger } from './pool-logging.js';
2
2
  import type { resolveModel } from './claude-spawner.js';
3
3
  import { type AccountIdentity, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
4
4
  import { type SubscriptionUsageStore } from './subscription-usage-store.js';
@@ -6,7 +6,13 @@ export type ResolvedPoolModel = ReturnType<typeof resolveModel>;
6
6
  export type ResolvedPoolSelection<A extends AccountIdentity> = (SelectionOutcome<A> | {
7
7
  kind: 'usage_unavailable';
8
8
  error: unknown;
9
- }) & ResolvedPoolModel;
9
+ }) & ResolvedPoolModel & {
10
+ candidates?: PoolSelectionCandidate[];
11
+ switch?: {
12
+ fromAccountId: string;
13
+ reason: string;
14
+ };
15
+ };
10
16
  /**
11
17
  * #405 integration seam: resolveModel ONCE before calling, then reuse the
12
18
  * returned model/modelSource for --model. No fetch, probe, spawn or env fallback
@@ -1,5 +1,5 @@
1
1
  import { logger } from './logger.js';
2
- import { logPoolSelection, poolInfo, poolLabel, poolErrorClass } from './pool-logging.js';
2
+ import { logPoolSelection, poolSelectionCandidates, poolInfo, poolLabel, poolErrorClass } from './pool-logging.js';
3
3
  import { selectSubscriptionAccount } from './subscription-selection.js';
4
4
  import { usageStateForSelection } from './subscription-usage-store.js';
5
5
  /**
@@ -33,5 +33,5 @@ export async function selectPoolForResolvedModel(resolvedModel, input) {
33
33
  const selected = selectSubscriptionAccount({ pool: input.pool, states, model: resolvedModel.model, now: input.now, currentAccountId: input.currentAccountId, currentAccountTolerance: input.currentAccountTolerance });
34
34
  if (input.log)
35
35
  logPoolSelection(input.log, input.pool, states, resolvedModel.model, input.now, selected, input.logAllExhausted);
36
- return { ...selected, ...resolvedModel };
36
+ return { ...selected, ...resolvedModel, candidates: poolSelectionCandidates(input.pool, states, resolvedModel.model, input.now, selected) };
37
37
  }
@@ -89,6 +89,12 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
89
89
  } & HeadroomScore & {
90
90
  model: string | undefined;
91
91
  modelSource: import("./claude-spawner.js").ModelSource;
92
+ } & {
93
+ candidates?: import("./pool-logging.js").PoolSelectionCandidate[];
94
+ switch?: {
95
+ fromAccountId: string;
96
+ reason: string;
97
+ };
92
98
  }) | ({
93
99
  kind: "selected";
94
100
  account: SubscriptionAccount;
@@ -97,6 +103,12 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
97
103
  } & {
98
104
  model: string | undefined;
99
105
  modelSource: import("./claude-spawner.js").ModelSource;
106
+ } & {
107
+ candidates?: import("./pool-logging.js").PoolSelectionCandidate[];
108
+ switch?: {
109
+ fromAccountId: string;
110
+ reason: string;
111
+ };
100
112
  });
101
113
  } | {
102
114
  account: undefined;
@@ -123,10 +123,13 @@ export async function preparePool(msg, resolved, pin, fetched, log, probeOptions
123
123
  const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log, currentAccountId, currentAccountTolerance: PIN_HEADROOM_SWITCH_RELATIVE });
124
124
  if (selected.kind === 'selected') {
125
125
  abortLocalProbe(selected.account);
126
- if (log && pool.kind === 'ready' && currentAccountId && currentAccountId !== selected.account.accountId) {
126
+ if (pool.kind === 'ready' && currentAccountId && currentAccountId !== selected.account.accountId) {
127
127
  const previous = pool.accounts.find(a => a.accountId === currentAccountId);
128
128
  const previousSelection = previous ? await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: { kind: 'ready', accounts: [previous] }, store, now: Date.now() }) : undefined;
129
- poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=${previousSelection?.kind === 'selected' ? 'better headroom' : 'pinned account unavailable'}`);
129
+ if (previous)
130
+ selected.switch = { fromAccountId: currentAccountId, reason: previousSelection?.kind === 'selected' ? 'better headroom' : 'pinned account unavailable' };
131
+ if (log)
132
+ poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=${previousSelection?.kind === 'selected' ? 'better headroom' : 'pinned account unavailable'}`);
130
133
  }
131
134
  if (pool.kind === 'ready')
132
135
  probeUnknownAccounts(pool.accounts, selected.account.accountId, resolved, store, {
@@ -88,6 +88,7 @@ export type SelectionOutcome<A extends AccountIdentity = AccountIdentity> = ({
88
88
  export declare function record(value: unknown): Record<string, unknown>;
89
89
  export declare function normalizePassiveUsage(value: unknown, observedAt: number): UsageObservation;
90
90
  export declare function normalizeProbeUsage(value: unknown, observedAt: number): UsageObservation;
91
+ export declare function family(model: string | undefined): 'sonnet' | 'opus' | 'haiku' | 'fable' | undefined;
91
92
  export declare function reactiveEligibleAt(reactive: ReactiveExhaustion): number;
92
93
  /** Pure policy. Unknown is eligible after every known/reactive blocker is ruled out. */
93
94
  export declare function selectSubscriptionAccount<A extends AccountIdentity>(input: {
@@ -143,7 +143,7 @@ export function normalizeProbeUsage(value, observedAt) {
143
143
  }
144
144
  return { source: 'probe', observedAt, windows };
145
145
  }
146
- function family(model) {
146
+ export function family(model) {
147
147
  return model?.toLowerCase().match(/^(?:claude-)?(sonnet|opus|haiku|fable)(?:-\d[\w.-]*)?(?:\[1m\])?$/)?.[1];
148
148
  }
149
149
  function applicable(scope, model) {
@@ -209,7 +209,7 @@ export function selectSubscriptionAccount(input) {
209
209
  }
210
210
  }
211
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)) : [];
212
- const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && !family(input.model)));
212
+ const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && input.model !== undefined && !family(input.model)));
213
213
  for (const w of windows) {
214
214
  if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
215
215
  blockers.push(usageWindowExpiresAt(usage, w));
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.6-rc.2",
3
+ "version": "0.8.6-rc.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.6-rc.2",
9
+ "version": "0.8.6-rc.3",
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.2",
3
+ "version": "0.8.6-rc.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {