@teamvibe/poller 0.8.5 → 0.8.6-rc.2

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.
@@ -248,6 +248,7 @@ export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: S
248
248
  export declare function spawnPoolClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, selectedDirectory: BrainDirectory | string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator, poolRuntime?: PoolRuntimeOptions): Promise<SpawnResult & {
249
249
  newSessionId?: string;
250
250
  }>;
251
+ export declare function isSubscriptionAccountRunning(account: Pick<SubscriptionAccount, 'workspaceId' | 'accountId'>): boolean;
251
252
  export declare function getActiveProcessCount(): number;
252
253
  export declare function getLastSessionEndTs(): number;
253
254
  /**
@@ -16,7 +16,7 @@ import { fetchSecretsForSpawn, buildSecretsReminder } from './secrets-client.js'
16
16
  import { fetchConfigForSpawn } from './config-client.js';
17
17
  import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
18
18
  import { parseClaudeQuotaFailure } from './claude-quota.js';
19
- import { accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
19
+ import { abortLocalProbe, accountPin, preparePool, fetchRuntimePool, validateAccount, poolDeferredError, PoolDeferredError } from './subscription-runtime.js';
20
20
  import { selectPoolForResolvedModel } from './subscription-pool.js';
21
21
  import { isQuotaPaused, QuotaPausedError } from './quota-pause.js';
22
22
  import { redactStartupText } from './startup-auth.js';
@@ -1023,6 +1023,10 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1023
1023
  ]));
1024
1024
  };
1025
1025
  warnTokenAliasSettings(spawnEnv['CLAUDE_CONFIG_DIR'], cwd, directory.path, sessionLog);
1026
+ // Preparation awaits remote calls: cancel again at the actual launch site.
1027
+ // No await between cancellation, spawn and running-account registration.
1028
+ if (runtime?.account)
1029
+ abortLocalProbe(runtime.account);
1026
1030
  const proc = spawn(config.CLAUDE_CLI_PATH, args, {
1027
1031
  cwd,
1028
1032
  env: spawnEnv,
@@ -1040,6 +1044,8 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1040
1044
  // Track active process by message ID and thread ID
1041
1045
  activeProcesses.set(msg.id, proc);
1042
1046
  activeProcessesByThread.set(msg.thread_id, proc);
1047
+ if (runtime?.account)
1048
+ activeSubscriptionAccounts.set(proc, runtime.account);
1043
1049
  for (const chunk of spawnStdinPlan.stdinChunks) {
1044
1050
  proc.stdin?.write(chunk);
1045
1051
  }
@@ -1310,6 +1316,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1310
1316
  clearNotificationTimers();
1311
1317
  activeProcesses.delete(msg.id);
1312
1318
  activeProcessesByThread.delete(msg.thread_id);
1319
+ activeSubscriptionAccounts.delete(proc);
1313
1320
  lastSessionEndTs = Date.now();
1314
1321
  // #73 gap 2 (flagged independently by two reviews): `close` firing means
1315
1322
  // `claude` itself has exited, but its MCP server children — spawned
@@ -1401,6 +1408,7 @@ async function runClaudeCode(msg, sessionLog, cwd, directory, sessionId, isFirst
1401
1408
  clearInterval(idleWatchdog);
1402
1409
  activeProcesses.delete(msg.id);
1403
1410
  activeProcessesByThread.delete(msg.thread_id);
1411
+ activeSubscriptionAccounts.delete(proc);
1404
1412
  lastSessionEndTs = Date.now();
1405
1413
  emitError('session', `Claude Code process spawn error: ${redactOutput(error.message)}`, {
1406
1414
  channelId: msg.teamvibe.channelId,
@@ -1568,7 +1576,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1568
1576
  const resolved = fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime?.pin?.workspaceId === msg.teamvibe.workspaceId
1569
1577
  ? { model: poolRuntime.pin.model, modelSource: poolRuntime.pin.modelSource }
1570
1578
  : resolveModel(typeof settings['model'] === 'string' ? settings['model'] : undefined, scoped ?? {}, config.CLAUDE_MODEL);
1571
- const prepared = poolRuntime ? await preparePool(msg, resolved, poolRuntime.pin, fetchedPool, sessionLog) : undefined;
1579
+ 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;
1572
1580
  const modelNormalizedFrom = scoped?.model === resolved.model && scoped?.modelSource === resolved.modelSource
1573
1581
  ? scoped?.modelNormalizedFrom : undefined;
1574
1582
  const runtime = poolRuntime ? { resolved, modelNormalizedFrom, claudeConfigDir, account: prepared?.account, shouldStop: poolRuntime.shouldStop, resumePending: fetchedPool?.kind !== 'no_accounts_configured' && poolRuntime.pin?.resumePending === true } : undefined;
@@ -1678,6 +1686,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1678
1686
  if (!effectiveSessionId)
1679
1687
  throw new PoolDeferredError('infrastructure');
1680
1688
  const fromAccount = prepared.account;
1689
+ abortLocalProbe(selected.account);
1681
1690
  poolInfo(sessionLog, `switch from=${poolLabel(fromAccount)} to=${poolLabel(selected.account)} session=${JSON.stringify(effectiveSessionId)}`);
1682
1691
  prepared.account = selected.account;
1683
1692
  prepared.selection = selected;
@@ -1733,6 +1742,10 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1733
1742
  }
1734
1743
  const activeProcesses = new Map();
1735
1744
  const activeProcessesByThread = new Map();
1745
+ const activeSubscriptionAccounts = new Map();
1746
+ export function isSubscriptionAccountRunning(account) {
1747
+ return [...activeSubscriptionAccounts.values()].some(active => active.workspaceId === account.workspaceId && active.accountId === account.accountId);
1748
+ }
1736
1749
  // tv#317: when the last `claude` process ended, so the idle-restart gate can require
1737
1750
  // a quiet period since real work last happened. Starts at module load (poller boot) so
1738
1751
  // a poller that never runs a session still becomes eligible once IDLE_RESTART_MIN_UPTIME_MS
@@ -1,7 +1,7 @@
1
1
  import type { PoolFetchDiagnostics } from './subscription-pool-client.js';
2
2
  import type { SessionLogger } from './logger.js';
3
3
  import type { ClaudeRateLimitInfo } from './claude-rate-limit.js';
4
- import { type AccountIdentity, type AccountUsageState, type PoolFetchResult, type SelectionOutcome } from './subscription-selection.js';
4
+ import { type AccountIdentity, type AccountUsageState, type PoolFetchResult, type SelectionOutcome, type UsageWindow } from './subscription-selection.js';
5
5
  export type PoolInfoLogger = Pick<SessionLogger, 'info'>;
6
6
  type LabeledAccount = AccountIdentity & {
7
7
  label?: string;
@@ -15,6 +15,7 @@ export declare function poolErrorClass(error: unknown): string;
15
15
  export declare function poolErrorDiagnostic(error: unknown): string;
16
16
  export declare function logPoolFetch(log: PoolInfoLogger, pool: PoolFetchResult & PoolFetchDiagnostics): void;
17
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;
18
+ export declare function formatPoolUsageWindows(windows: readonly UsageWindow[], includeScope?: boolean): string;
18
19
  /** Log the real store promise without changing its rejection or retry behavior. */
19
20
  export declare function logPoolUsageWrite(log: PoolInfoLogger, account: {
20
21
  label?: string;
@@ -1,4 +1,4 @@
1
- import { redactStartupText } from './startup-auth.js';
1
+ import { redactStartupText } from './redact-text.js';
2
2
  import { normalizePassiveUsage, selectSubscriptionAccount } from './subscription-selection.js';
3
3
  import { safeRateLimitInfo } from './subscription-usage-store.js';
4
4
  // Never serialize accounts, credentials, provider strings or caught errors.
@@ -87,9 +87,12 @@ export function logPoolSelection(log, pool, states, model, now, selected, logAll
87
87
  poolInfo(log, `selected account=${poolLabel(selected.account)} model=${JSON.stringify(model ?? 'CLI default')} score=${score}`);
88
88
  }
89
89
  }
90
+ export function formatPoolUsageWindows(windows, includeScope = true) {
91
+ return windows.map(w => `${w.window}${includeScope ? `/${w.scope}` : ''}:${w.percent === undefined ? 'unknown' : `${Number(w.percent.toFixed(2))}%`}`).join(',') || 'none';
92
+ }
90
93
  /** Log the real store promise without changing its rejection or retry behavior. */
91
94
  export async function logPoolUsageWrite(log, account, info, write, source = 'passive') {
92
- const windows = info ? normalizePassiveUsage(safeRateLimitInfo(info), Date.now()).windows.map(w => `${w.window}${source === 'passive' ? `/${w.scope}` : ''}:${w.percent === undefined ? 'unknown' : `${Number(w.percent.toFixed(2))}%`}`).join(',') : '';
95
+ const windows = formatPoolUsageWindows(info ? normalizePassiveUsage(safeRateLimitInfo(info), Date.now()).windows : [], source === 'passive');
93
96
  try {
94
97
  await write();
95
98
  poolInfo(log, `usage write account=${poolLabel(account)} result=ok windows=${windows || 'none'} source=${source}`);
@@ -20,6 +20,7 @@ export declare function selectPoolForResolvedModel<A extends AccountIdentity & {
20
20
  store: SubscriptionUsageStore;
21
21
  now: number;
22
22
  currentAccountId?: string;
23
+ currentAccountTolerance?: number;
23
24
  log?: PoolInfoLogger;
24
25
  logAllExhausted?: boolean;
25
26
  }): Promise<ResolvedPoolSelection<A>>;
@@ -30,7 +30,7 @@ export async function selectPoolForResolvedModel(resolvedModel, input) {
30
30
  catch (error) {
31
31
  return { kind: 'usage_unavailable', error, ...resolvedModel };
32
32
  }
33
- const selected = selectSubscriptionAccount({ pool: input.pool, states, model: resolvedModel.model, now: input.now, currentAccountId: input.currentAccountId });
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
36
  return { ...selected, ...resolvedModel };
@@ -1,3 +1,4 @@
1
+ import { type UsageProbeOptions } from './usage-probe-job.js';
1
2
  import { type PoolInfoLogger } from './pool-logging.js';
2
3
  import { type PoolFetchDiagnostics, type SubscriptionAccount } from './subscription-pool-client.js';
3
4
  import { type ResolvedPoolModel } from './subscription-pool.js';
@@ -51,7 +52,15 @@ export interface PoolRuntimeOptions {
51
52
  }
52
53
  export declare function subscriptionUsageStore(): FileSubscriptionUsageStore;
53
54
  export declare function fetchRuntimePool(msg: TeamVibeQueueMessage): Promise<PoolFetchResult<SubscriptionAccount> & PoolFetchDiagnostics>;
54
- export declare function preparePool(msg: TeamVibeQueueMessage, resolved: ResolvedPoolModel, pin?: SubscriptionPin, fetched?: PoolFetchResult<SubscriptionAccount>, log?: PoolInfoLogger): Promise<{
55
+ /** Minimum gap between local measurements of one workspace/account/credential. */
56
+ export declare const LOCAL_PROBE_MIN_INTERVAL_MS = 60000;
57
+ /** Bound background CLI load across every workspace in this poller process. */
58
+ export declare const LOCAL_PROBE_MAX_CONCURRENT = 3;
59
+ /** A local measurement has its own deadline, independent of session lifetime. */
60
+ export declare const LOCAL_PROBE_TIMEOUT_MS = 30000;
61
+ /** Synchronous: kill the isolated child and free its slot before session spawn. */
62
+ export declare function abortLocalProbe(account: SubscriptionAccount): void;
63
+ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: ResolvedPoolModel, pin?: SubscriptionPin, fetched?: PoolFetchResult<SubscriptionAccount>, log?: PoolInfoLogger, probeOptions?: Omit<UsageProbeOptions, 'store'>): Promise<{
55
64
  account: SubscriptionAccount;
56
65
  store: FileSubscriptionUsageStore;
57
66
  pool: {
@@ -1,3 +1,7 @@
1
+ import { isAtCapacity, isSubscriptionAccountRunning } from './claude-spawner.js';
2
+ import { emitEvent } from './event-queue.js';
3
+ import { logger } from './logger.js';
4
+ import { runUsageProbe } from './usage-probe-job.js';
1
5
  import { poolInfo, poolLabel, poolErrorDiagnostic } from './pool-logging.js';
2
6
  import { emitCandidateUsage } from './pool-telemetry.js';
3
7
  import { join } from 'node:path';
@@ -6,7 +10,7 @@ import { getPollerId } from './auth-provider.js';
6
10
  import { fetchSubscriptionPool } from './subscription-pool-client.js';
7
11
  import { selectPoolForResolvedModel } from './subscription-pool.js';
8
12
  import { FileSubscriptionUsageStore, usageStateForSelection } from './subscription-usage-store.js';
9
- import { normalizePassiveUsage, selectSubscriptionAccount, usageWindowExpiresAt, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
13
+ import { normalizePassiveUsage, selectSubscriptionAccount, usageWindowExpiresAt, reactiveEligibleAt, PIN_HEADROOM_SWITCH_RELATIVE, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
10
14
  export const POOL_UNAVAILABLE_RECHECK_MS = 60_000;
11
15
  export class PoolDeferredError extends Error {
12
16
  cause;
@@ -31,31 +35,104 @@ export function subscriptionUsageStore() {
31
35
  export async function fetchRuntimePool(msg) {
32
36
  return fetchSubscriptionPool({ pollerId: getPollerId() || '', workspaceId: msg.teamvibe.workspaceId, channelId: msg.teamvibe.channelId }, { apiUrl: config.TEAMVIBE_API_URL, pollerToken: config.TEAMVIBE_POLLER_TOKEN || '' });
33
37
  }
34
- export async function preparePool(msg, resolved, pin, fetched, log) {
38
+ /** Minimum gap between local measurements of one workspace/account/credential. */
39
+ export const LOCAL_PROBE_MIN_INTERVAL_MS = 60_000;
40
+ /** Bound background CLI load across every workspace in this poller process. */
41
+ export const LOCAL_PROBE_MAX_CONCURRENT = 3;
42
+ /** A local measurement has its own deadline, independent of session lifetime. */
43
+ export const LOCAL_PROBE_TIMEOUT_MS = 30_000;
44
+ const localProbesInFlight = new Map();
45
+ /** Synchronous: kill the isolated child and free its slot before session spawn. */
46
+ export function abortLocalProbe(account) {
47
+ for (const [key, probe] of localProbesInFlight) {
48
+ // A credential rotation must not leave an older probe on the same account.
49
+ if (probe.account.workspaceId !== account.workspaceId || probe.account.accountId !== account.accountId)
50
+ continue;
51
+ localProbesInFlight.delete(key);
52
+ probe.controller.abort();
53
+ }
54
+ }
55
+ 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;
64
+ 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
+ });
103
+ }
104
+ 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);
111
+ }
112
+ })();
113
+ }
114
+ }
115
+ export async function preparePool(msg, resolved, pin, fetched, log, probeOptions) {
35
116
  const pool = fetched ?? await fetchRuntimePool(msg);
36
117
  const store = subscriptionUsageStore();
37
118
  if (pool.kind === 'ready')
38
119
  await emitCandidateUsage(msg, pool.accounts, store);
39
- if (pool.kind === 'ready' && pin?.workspaceId === msg.teamvibe.workspaceId) {
40
- const account = pool.accounts.find(a => a.accountId === pin.accountId);
41
- if (account) {
42
- const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool: { ...pool, accounts: [account] }, store, now: Date.now(), log, logAllExhausted: false });
43
- if (selected.kind === 'selected') {
44
- if (log)
45
- for (const other of pool.accounts) {
46
- if (other.accountId !== account.accountId)
47
- poolInfo(log, `skipped account=${poolLabel(other)} reason=session pinned elsewhere`);
48
- }
49
- return { account, store, pool, selection: selected };
50
- }
51
- }
52
- }
53
- const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log, currentAccountId: pin?.workspaceId === msg.teamvibe.workspaceId ? pin.accountId : undefined });
120
+ const currentAccountId = pin?.workspaceId === msg.teamvibe.workspaceId ? pin.accountId : undefined;
121
+ // A pin is a spawn-boundary preference. Reactive same-session switches keep
122
+ // their existing strict ranking and do not receive this tolerance.
123
+ const selected = await selectPoolForResolvedModel(resolved, { workspaceId: msg.teamvibe.workspaceId, pool, store, now: Date.now(), log, currentAccountId, currentAccountTolerance: PIN_HEADROOM_SWITCH_RELATIVE });
54
124
  if (selected.kind === 'selected') {
55
- if (log && pool.kind === 'ready' && pin?.workspaceId === msg.teamvibe.workspaceId && pin.accountId !== selected.account.accountId) {
56
- const previous = pool.accounts.find(a => a.accountId === pin.accountId);
57
- poolInfo(log, `switch from=${poolLabel(previous ?? {})} to=${poolLabel(selected.account)} reason=pinned account unavailable`);
125
+ abortLocalProbe(selected.account);
126
+ if (log && pool.kind === 'ready' && currentAccountId && currentAccountId !== selected.account.accountId) {
127
+ const previous = pool.accounts.find(a => a.accountId === currentAccountId);
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'}`);
58
130
  }
131
+ if (pool.kind === 'ready')
132
+ probeUnknownAccounts(pool.accounts, selected.account.accountId, resolved, store, {
133
+ cliPath: config.CLAUDE_CLI_PATH, isAtCapacity, emit: emitEvent,
134
+ log: line => (log ?? logger).info(line), ...probeOptions, store,
135
+ }, msg.teamvibe.channelId);
59
136
  return { account: selected.account, store, pool, selection: selected };
60
137
  }
61
138
  if (selected.kind === 'no_accounts_configured')
@@ -1,8 +1,11 @@
1
1
  import type { AccountProbeFailureReason } from './usage-probe-contract.js';
2
2
  import type { ClaudeQuotaFailure } from './claude-quota.js';
3
+ /** Switch a spawn-boundary pin when the best score is at least 25% higher. */
4
+ export declare const PIN_HEADROOM_SWITCH_RELATIVE = 0.25;
3
5
  export declare const USAGE_THRESHOLD_PERCENT = 90;
4
6
  /** Fallback freshness for passive windows without a reset time. */
5
7
  export declare const PASSIVE_USAGE_TTL_MS: number;
8
+ /** Fallback freshness for probe windows without a reset time. */
6
9
  export declare const PROBE_USAGE_TTL_MS = 60000;
7
10
  export declare const POOL_RECHECK_MS: number;
8
11
  /** Safety hold from the rejection observation when the provider gives no reset. */
@@ -49,7 +52,7 @@ export interface UsageObservation {
49
52
  observedAt: number;
50
53
  windows: readonly UsageWindow[];
51
54
  }
52
- /** Passive percentages remain lower bounds until reset; probes retain their short TTL. */
55
+ /** Passive and probe percentages remain lower bounds until each window resets. */
53
56
  export declare function usageWindowExpiresAt(usage: UsageObservation, window: UsageWindow): number;
54
57
  /** Latest measurements plus durable rejections; omission or success never clears a hold. */
55
58
  export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
@@ -92,7 +95,8 @@ export declare function selectSubscriptionAccount<A extends AccountIdentity>(inp
92
95
  /** Stable account ID within the pool's single workspace. */
93
96
  states: Readonly<Record<string, AccountUsageState | undefined>>;
94
97
  model: string | undefined;
95
- /** Tie preference only; session pins are enforced separately by the runtime. */
98
+ /** Tie preference by default; runtime pins can also supply a relative tolerance. */
96
99
  currentAccountId?: string;
100
+ currentAccountTolerance?: number;
97
101
  now: number;
98
102
  }): SelectionOutcome<A>;
@@ -1,14 +1,17 @@
1
+ /** Switch a spawn-boundary pin when the best score is at least 25% higher. */
2
+ export const PIN_HEADROOM_SWITCH_RELATIVE = 0.25;
1
3
  export const USAGE_THRESHOLD_PERCENT = 90;
2
4
  /** Fallback freshness for passive windows without a reset time. */
3
5
  export const PASSIVE_USAGE_TTL_MS = 5 * 60_000;
6
+ /** Fallback freshness for probe windows without a reset time. */
4
7
  export const PROBE_USAGE_TTL_MS = 60_000;
5
8
  export const POOL_RECHECK_MS = 15 * 60_000;
6
9
  /** Safety hold from the rejection observation when the provider gives no reset. */
7
10
  export const REJECTED_BLOCK_MS = 15 * 60_000;
8
- /** Passive percentages remain lower bounds until reset; probes retain their short TTL. */
11
+ /** Passive and probe percentages remain lower bounds until each window resets. */
9
12
  export function usageWindowExpiresAt(usage, window) {
10
13
  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);
14
+ return window.resetAt ?? ttlExpiry;
12
15
  }
13
16
  /** Latest measurements plus durable rejections; omission or success never clears a hold. */
14
17
  export function mergeUsageObservations(observations, now) {
@@ -223,7 +226,15 @@ export function selectSubscriptionAccount(input) {
223
226
  }
224
227
  const compare = (a, b) => a.accountId < b.accountId ? -1 : a.accountId > b.accountId ? 1 : 0;
225
228
  known.sort((a, b) => b.score - a.score || Number(b.account.accountId === input.currentAccountId) - Number(a.account.accountId === input.currentAccountId) || compare(a.account, b.account));
226
- unknown.sort((a, b) => compare(a.account, b.account));
229
+ // Unknown pin preference is opt-in for preparePool; preserve the default
230
+ // policy's stable ID order (also used by the server and reactive selection).
231
+ unknown.sort((a, b) => (input.currentAccountTolerance === undefined ? 0 : Number(b.account.accountId === input.currentAccountId) - Number(a.account.accountId === input.currentAccountId)) || compare(a.account, b.account));
232
+ const current = known.find(candidate => candidate.account.accountId === input.currentAccountId);
233
+ if (current && known[0] && input.currentAccountTolerance !== undefined &&
234
+ known[0].score < current.score * (1 + input.currentAccountTolerance))
235
+ return { kind: 'selected', provenance: 'known', ...current };
236
+ // Known-first is only this spawn's transitional fallback: background CLI
237
+ // probes measure non-selected unknowns so the next spawn can score them.
227
238
  if (known[0])
228
239
  return { kind: 'selected', provenance: 'known', ...known[0] };
229
240
  if (unknown[0])
@@ -29,6 +29,7 @@ export interface SubscriptionUsageStore {
29
29
  recordQuota(key: UsageKey, quota: ClaudeQuotaFailure, observedAt: number, options?: UsageWriteOptions): Promise<void>;
30
30
  /** Persist BEFORE network I/O; one attempt per credential version, including restart. */
31
31
  beginProbe(key: UsageKey, observedAt: number): Promise<boolean>;
32
+ claimLocalProbe(key: UsageKey, observedAt: number, minIntervalMs: number): Promise<boolean>;
32
33
  claimProbeRequest(requestId: string, expiresAt: number): Promise<boolean>;
33
34
  finishProbe(key: UsageKey, observation: ProbeObservation): Promise<void>;
34
35
  }
@@ -46,6 +47,8 @@ export declare class FileSubscriptionUsageStore implements SubscriptionUsageStor
46
47
  recordPassive(key: UsageKey, rateLimitInfo: ClaudeRateLimitInfo, observedAt: number, options?: UsageWriteOptions): Promise<void>;
47
48
  recordQuota(key: UsageKey, quota: ClaudeQuotaFailure, observedAt: number, options?: UsageWriteOptions): Promise<void>;
48
49
  beginProbe(key: UsageKey, observedAt: number): Promise<boolean>;
50
+ /** Atomic across store instances; separate from optional HTTP and platform claims. */
51
+ claimLocalProbe(key: UsageKey, observedAt: number, minIntervalMs: number): Promise<boolean>;
49
52
  /** Request claims live in the local data directory, alongside usage, never in a brain. */
50
53
  claimProbeRequest(requestId: string, expiresAt: number): Promise<boolean>;
51
54
  finishProbe(key: UsageKey, observation: ProbeObservation): Promise<void>;
@@ -103,6 +103,10 @@ function safeProbe(value) {
103
103
  }
104
104
  function safeVersion(value) {
105
105
  const state = {};
106
+ if (value.localProbeClaimedAt !== undefined) {
107
+ validTime(value.localProbeClaimedAt);
108
+ state.localProbeClaimedAt = value.localProbeClaimedAt;
109
+ }
106
110
  if (value.passive) {
107
111
  validTime(value.passive.observedAt);
108
112
  state.passive = { observedAt: value.passive.observedAt, rateLimitInfo: safeRateLimitInfo(value.passive.rateLimitInfo) };
@@ -220,6 +224,20 @@ export class FileSubscriptionUsageStore {
220
224
  return true;
221
225
  });
222
226
  }
227
+ /** Atomic across store instances; separate from optional HTTP and platform claims. */
228
+ async claimLocalProbe(key, observedAt, minIntervalMs) {
229
+ validTime(observedAt);
230
+ const path = this.#path(key, true);
231
+ return serial(path, async () => {
232
+ const state = safeVersion(await this.#load(path, {}));
233
+ if (state.localProbeClaimedAt !== undefined && observedAt >= state.localProbeClaimedAt &&
234
+ observedAt - state.localProbeClaimedAt < minIntervalMs)
235
+ return false;
236
+ state.localProbeClaimedAt = observedAt;
237
+ await this.#save(path, state);
238
+ return true;
239
+ });
240
+ }
223
241
  /** Request claims live in the local data directory, alongside usage, never in a brain. */
224
242
  async claimProbeRequest(requestId, expiresAt) {
225
243
  validTime(expiresAt);
@@ -1,17 +1,34 @@
1
+ import { spawn } from 'node:child_process';
1
2
  import type { UsageProbeMessage } from './types.js';
2
3
  import type { PoolFetchResult } from './subscription-selection.js';
3
4
  import type { SubscriptionAccount } from './subscription-pool-client.js';
4
5
  import { type SubscriptionUsageStore } from './subscription-usage-store.js';
5
- interface Options {
6
- fetchPool: () => Promise<PoolFetchResult<SubscriptionAccount>>;
6
+ import type { UsageProbeFailureReason as Reason } from './usage-probe-contract.js';
7
+ type ProbeResult = Reason | 'ok' | 'cancelled';
8
+ export interface UsageProbeOptions {
7
9
  store: SubscriptionUsageStore;
8
10
  cliPath: string;
11
+ /** Use the session's resolved model for local probes; platform jobs default to CLI behavior. */
12
+ model?: string;
9
13
  timeoutMs?: number;
14
+ /** Injectable child for deterministic tests; production uses node spawn. */
15
+ spawn?: typeof spawn;
10
16
  isAtCapacity: () => boolean;
11
17
  emit: (type: string, channelId: string, metadata: Record<string, unknown>) => void;
12
18
  log: (line: string) => void;
13
19
  }
20
+ interface Options extends UsageProbeOptions {
21
+ fetchPool: () => Promise<PoolFetchResult<SubscriptionAccount>>;
22
+ }
14
23
  export declare function isUsageProbe(value: unknown): value is UsageProbeMessage;
15
24
  /** This run deliberately has no message/session/brain context or normal spawn bookkeeping. */
16
25
  export declare function handleUsageProbe(job: UsageProbeMessage, options: Options): Promise<void>;
26
+ /** Shared isolated CLI measurement; independent of any triggering session. */
27
+ export declare function runUsageProbe(account: SubscriptionAccount, options: UsageProbeOptions, context?: {
28
+ requestId?: string;
29
+ expiresAt?: number;
30
+ channelId?: string;
31
+ signal?: AbortSignal;
32
+ onMeasurementComplete?: () => void;
33
+ }): Promise<ProbeResult>;
17
34
  export {};
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { normalizePassiveUsage } from './subscription-selection.js';
6
6
  import { safeRateLimitInfo, usageStateForSelection, } from './subscription-usage-store.js';
7
7
  import { parseClaudeRateLimitInfo, } from './claude-rate-limit.js';
8
+ import { formatPoolUsageWindows, poolInfo, poolLabel } from './pool-logging.js';
8
9
  export function isUsageProbe(value) {
9
10
  return (!!value &&
10
11
  typeof value === 'object' &&
@@ -14,7 +15,7 @@ export function isUsageProbe(value) {
14
15
  export async function handleUsageProbe(job, options) {
15
16
  let result = 'unknown';
16
17
  let duplicate = false;
17
- let cwd;
18
+ let probed = false;
18
19
  if (!['workspaceId', 'accountId', 'credentialVersion', 'requestId'].every((k) => {
19
20
  const value = job[k];
20
21
  return (typeof value === 'string' &&
@@ -63,9 +64,36 @@ export async function handleUsageProbe(job, options) {
63
64
  result = 'cli_at_capacity';
64
65
  return;
65
66
  }
67
+ probed = true;
68
+ result = await runUsageProbe(account, options, { requestId: job.requestId, expiresAt: job.expiresAt });
69
+ }
70
+ catch {
71
+ result = 'unknown';
72
+ }
73
+ finally {
74
+ if (!duplicate && result !== 'ok' && result !== 'cancelled')
75
+ options.emit('pool.probe_failed', 'usage_probe', {
76
+ ...metadata,
77
+ reason: result,
78
+ });
79
+ if (!duplicate && !probed)
80
+ options.log(`[Pool] usage probe request=${job.requestId} result=${result}`);
81
+ }
82
+ }
83
+ /** Shared isolated CLI measurement; independent of any triggering session. */
84
+ export async function runUsageProbe(account, options, context = {}) {
85
+ let result = 'unknown';
86
+ let cwd;
87
+ let measuredWindows;
88
+ const cancelled = () => context.signal?.aborted === true;
89
+ try {
90
+ if (cancelled())
91
+ return result = 'cancelled';
66
92
  cwd = await mkdtemp(join(tmpdir(), 'teamvibe-usage-'));
67
93
  const configDir = join(cwd, 'config');
68
94
  await mkdir(configDir);
95
+ if (cancelled())
96
+ return result = 'cancelled';
69
97
  // Allow only OS execution essentials. No ambient credentials, Slack, API,
70
98
  // brain, hooks, or user's Claude settings reach this isolated child.
71
99
  const env = {
@@ -79,9 +107,10 @@ export async function handleUsageProbe(job, options) {
79
107
  let info;
80
108
  const outcome = await new Promise((resolve) => {
81
109
  let done = false, buffer = '', errorText = '';
82
- const child = spawn(options.cliPath, [
110
+ const child = (options.spawn ?? spawn)(options.cliPath, [
83
111
  '-p',
84
112
  'Reply OK.',
113
+ ...(options.model ? ['--model', options.model] : []),
85
114
  '--output-format',
86
115
  'stream-json',
87
116
  '--verbose',
@@ -105,18 +134,25 @@ export async function handleUsageProbe(job, options) {
105
134
  child.kill('SIGKILL');
106
135
  }
107
136
  }
137
+ else
138
+ child.kill('SIGKILL');
108
139
  };
109
140
  const finish = (reason) => {
110
141
  if (done)
111
142
  return;
112
143
  done = true;
113
144
  clearTimeout(timer);
145
+ context.signal?.removeEventListener('abort', onAbort);
114
146
  resolve(reason);
115
147
  };
116
- const timer = setTimeout(() => {
148
+ const onAbort = () => {
149
+ finish('cancelled');
117
150
  kill();
151
+ };
152
+ const timer = setTimeout(() => {
118
153
  finish('timeout');
119
- }, Math.min(options.timeoutMs ?? 60_000, Math.max(1, job.expiresAt - Date.now())));
154
+ kill();
155
+ }, Math.min(options.timeoutMs ?? 60_000, Math.max(1, (context.expiresAt ?? Infinity) - Date.now())));
120
156
  const line = (text) => {
121
157
  try {
122
158
  const event = JSON.parse(text);
@@ -128,6 +164,8 @@ export async function handleUsageProbe(job, options) {
128
164
  catch { }
129
165
  };
130
166
  child.stdout.on('data', (chunk) => {
167
+ if (done)
168
+ return;
131
169
  buffer += chunk.toString();
132
170
  if (buffer.length > 1024 * 1024) {
133
171
  kill();
@@ -147,6 +185,8 @@ export async function handleUsageProbe(job, options) {
147
185
  // Quota rejection is still a successful observation: it reveals reset
148
186
  // time without requiring the account to complete an assistant turn.
149
187
  child.once('close', (code) => {
188
+ if (done)
189
+ return;
150
190
  if (buffer)
151
191
  line(buffer);
152
192
  finish(/401|unauthorized|invalid.*token|invalid api key|authentication.*(?:fail|error)/i.test(errorText)
@@ -157,43 +197,57 @@ export async function handleUsageProbe(job, options) {
157
197
  ? 'ok'
158
198
  : 'unknown');
159
199
  });
200
+ context.signal?.addEventListener('abort', onAbort, { once: true });
201
+ if (cancelled())
202
+ onAbort();
160
203
  });
161
- result = outcome;
204
+ result = cancelled() ? 'cancelled' : outcome;
205
+ // The CLI has finished. Stop treating this as an in-flight measurement
206
+ // before publishing its result, so a later selection cannot retroactively
207
+ // cancel an observation whose store write has already started.
208
+ context.onMeasurementComplete?.();
162
209
  if (result !== 'ok' || !info)
163
- return;
210
+ return result;
164
211
  const now = Date.now();
165
212
  const safe = safeRateLimitInfo(info);
166
213
  const usage = {
167
214
  ...normalizePassiveUsage(safe, now),
168
215
  source: 'probe',
169
216
  };
217
+ measuredWindows = formatPoolUsageWindows(usage.windows);
170
218
  await options.store.finishProbe(account, {
171
219
  status: 'ok',
172
220
  observedAt: now,
173
221
  usage,
174
222
  });
175
- options.emit('pool.usage_observed', 'usage_probe', {
176
- ...metadata,
223
+ const stored = await options.store.read(account);
224
+ // Requestless local observations use real channel authorization; the
225
+ // workspace-job namespace is reserved for platform request IDs.
226
+ options.emit('pool.usage_observed', context.channelId ?? 'usage_probe', {
227
+ workspaceId: account.workspaceId,
228
+ accountId: account.accountId,
229
+ credentialVersion: account.credentialVersion,
230
+ ...(context.requestId ? { requestId: context.requestId } : {}),
231
+ source: 'probe',
177
232
  accountLabel: account.label ?? account.accountId,
178
233
  reportedAt: now,
179
- usage: usageStateForSelection(await options.store.read(account), now).usage,
234
+ usage: usageStateForSelection(stored, now).usage,
180
235
  lastRateLimitInfo: safe,
181
236
  probeStatus: 'ok',
182
237
  probeObservedAt: now,
183
238
  });
239
+ return result;
184
240
  }
185
241
  catch {
186
- result = 'unknown';
242
+ result = cancelled() ? 'cancelled' : 'unknown';
243
+ return result;
187
244
  }
188
245
  finally {
189
246
  if (cwd)
190
247
  await rm(cwd, { recursive: true, force: true }).catch(() => { });
191
- if (!duplicate && result !== 'ok')
192
- options.emit('pool.probe_failed', 'usage_probe', {
193
- ...metadata,
194
- reason: result,
195
- });
196
- if (!duplicate)
197
- options.log(`[Pool] usage probe request=${job.requestId} result=${result}`);
248
+ const description = context.requestId
249
+ ? `usage probe request=${context.requestId}`
250
+ : `local probe account=${poolLabel(account)}`;
251
+ poolInfo({ info: options.log }, `${description} result=${result}${result === 'ok' && !context.requestId ? ` windows=${measuredWindows}` : ''}${result === 'cancelled' ? ' reason=account selected' : ''}`);
198
252
  }
199
253
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.5",
3
+ "version": "0.8.6-rc.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.5",
9
+ "version": "0.8.6-rc.2",
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.5",
3
+ "version": "0.8.6-rc.2",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {