@teamvibe/poller 0.8.5 → 0.8.6-rc.1

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,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.
@@ -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, 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,5 +1,7 @@
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;
@@ -92,7 +94,8 @@ export declare function selectSubscriptionAccount<A extends AccountIdentity>(inp
92
94
  /** Stable account ID within the pool's single workspace. */
93
95
  states: Readonly<Record<string, AccountUsageState | undefined>>;
94
96
  model: string | undefined;
95
- /** Tie preference only; session pins are enforced separately by the runtime. */
97
+ /** Tie preference by default; runtime pins can also supply a relative tolerance. */
96
98
  currentAccountId?: string;
99
+ currentAccountTolerance?: number;
97
100
  now: number;
98
101
  }): SelectionOutcome<A>;
@@ -1,3 +1,5 @@
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;
@@ -223,7 +225,15 @@ export function selectSubscriptionAccount(input) {
223
225
  }
224
226
  const compare = (a, b) => a.accountId < b.accountId ? -1 : a.accountId > b.accountId ? 1 : 0;
225
227
  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));
228
+ // Unknown pin preference is opt-in for preparePool; preserve the default
229
+ // policy's stable ID order (also used by the server and reactive selection).
230
+ 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));
231
+ const current = known.find(candidate => candidate.account.accountId === input.currentAccountId);
232
+ if (current && known[0] && input.currentAccountTolerance !== undefined &&
233
+ known[0].score < current.score * (1 + input.currentAccountTolerance))
234
+ return { kind: 'selected', provenance: 'known', ...current };
235
+ // Known-first is only this spawn's transitional fallback: background CLI
236
+ // probes measure non-selected unknowns so the next spawn can score them.
227
237
  if (known[0])
228
238
  return { kind: 'selected', provenance: 'known', ...known[0] };
229
239
  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,32 @@
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;
9
11
  timeoutMs?: number;
12
+ /** Injectable child for deterministic tests; production uses node spawn. */
13
+ spawn?: typeof spawn;
10
14
  isAtCapacity: () => boolean;
11
15
  emit: (type: string, channelId: string, metadata: Record<string, unknown>) => void;
12
16
  log: (line: string) => void;
13
17
  }
18
+ interface Options extends UsageProbeOptions {
19
+ fetchPool: () => Promise<PoolFetchResult<SubscriptionAccount>>;
20
+ }
14
21
  export declare function isUsageProbe(value: unknown): value is UsageProbeMessage;
15
22
  /** This run deliberately has no message/session/brain context or normal spawn bookkeeping. */
16
23
  export declare function handleUsageProbe(job: UsageProbeMessage, options: Options): Promise<void>;
24
+ /** Shared isolated CLI measurement; independent of any triggering session. */
25
+ export declare function runUsageProbe(account: SubscriptionAccount, options: UsageProbeOptions, context?: {
26
+ requestId?: string;
27
+ expiresAt?: number;
28
+ channelId?: string;
29
+ signal?: AbortSignal;
30
+ onMeasurementComplete?: () => void;
31
+ }): Promise<ProbeResult>;
17
32
  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 { 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,35 @@ 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
+ const cancelled = () => context.signal?.aborted === true;
88
+ try {
89
+ if (cancelled())
90
+ return result = 'cancelled';
66
91
  cwd = await mkdtemp(join(tmpdir(), 'teamvibe-usage-'));
67
92
  const configDir = join(cwd, 'config');
68
93
  await mkdir(configDir);
94
+ if (cancelled())
95
+ return result = 'cancelled';
69
96
  // Allow only OS execution essentials. No ambient credentials, Slack, API,
70
97
  // brain, hooks, or user's Claude settings reach this isolated child.
71
98
  const env = {
@@ -79,7 +106,7 @@ export async function handleUsageProbe(job, options) {
79
106
  let info;
80
107
  const outcome = await new Promise((resolve) => {
81
108
  let done = false, buffer = '', errorText = '';
82
- const child = spawn(options.cliPath, [
109
+ const child = (options.spawn ?? spawn)(options.cliPath, [
83
110
  '-p',
84
111
  'Reply OK.',
85
112
  '--output-format',
@@ -105,18 +132,25 @@ export async function handleUsageProbe(job, options) {
105
132
  child.kill('SIGKILL');
106
133
  }
107
134
  }
135
+ else
136
+ child.kill('SIGKILL');
108
137
  };
109
138
  const finish = (reason) => {
110
139
  if (done)
111
140
  return;
112
141
  done = true;
113
142
  clearTimeout(timer);
143
+ context.signal?.removeEventListener('abort', onAbort);
114
144
  resolve(reason);
115
145
  };
116
- const timer = setTimeout(() => {
146
+ const onAbort = () => {
147
+ finish('cancelled');
117
148
  kill();
149
+ };
150
+ const timer = setTimeout(() => {
118
151
  finish('timeout');
119
- }, Math.min(options.timeoutMs ?? 60_000, Math.max(1, job.expiresAt - Date.now())));
152
+ kill();
153
+ }, Math.min(options.timeoutMs ?? 60_000, Math.max(1, (context.expiresAt ?? Infinity) - Date.now())));
120
154
  const line = (text) => {
121
155
  try {
122
156
  const event = JSON.parse(text);
@@ -128,6 +162,8 @@ export async function handleUsageProbe(job, options) {
128
162
  catch { }
129
163
  };
130
164
  child.stdout.on('data', (chunk) => {
165
+ if (done)
166
+ return;
131
167
  buffer += chunk.toString();
132
168
  if (buffer.length > 1024 * 1024) {
133
169
  kill();
@@ -147,6 +183,8 @@ export async function handleUsageProbe(job, options) {
147
183
  // Quota rejection is still a successful observation: it reveals reset
148
184
  // time without requiring the account to complete an assistant turn.
149
185
  child.once('close', (code) => {
186
+ if (done)
187
+ return;
150
188
  if (buffer)
151
189
  line(buffer);
152
190
  finish(/401|unauthorized|invalid.*token|invalid api key|authentication.*(?:fail|error)/i.test(errorText)
@@ -157,10 +195,17 @@ export async function handleUsageProbe(job, options) {
157
195
  ? 'ok'
158
196
  : 'unknown');
159
197
  });
198
+ context.signal?.addEventListener('abort', onAbort, { once: true });
199
+ if (cancelled())
200
+ onAbort();
160
201
  });
161
- result = outcome;
202
+ result = cancelled() ? 'cancelled' : outcome;
203
+ // The CLI has finished. Stop treating this as an in-flight measurement
204
+ // before publishing its result, so a later selection cannot retroactively
205
+ // cancel an observation whose store write has already started.
206
+ context.onMeasurementComplete?.();
162
207
  if (result !== 'ok' || !info)
163
- return;
208
+ return result;
164
209
  const now = Date.now();
165
210
  const safe = safeRateLimitInfo(info);
166
211
  const usage = {
@@ -172,28 +217,34 @@ export async function handleUsageProbe(job, options) {
172
217
  observedAt: now,
173
218
  usage,
174
219
  });
175
- options.emit('pool.usage_observed', 'usage_probe', {
176
- ...metadata,
220
+ const stored = await options.store.read(account);
221
+ // Requestless local observations use real channel authorization; the
222
+ // workspace-job namespace is reserved for platform request IDs.
223
+ options.emit('pool.usage_observed', context.channelId ?? 'usage_probe', {
224
+ workspaceId: account.workspaceId,
225
+ accountId: account.accountId,
226
+ credentialVersion: account.credentialVersion,
227
+ ...(context.requestId ? { requestId: context.requestId } : {}),
228
+ source: 'probe',
177
229
  accountLabel: account.label ?? account.accountId,
178
230
  reportedAt: now,
179
- usage: usageStateForSelection(await options.store.read(account), now).usage,
231
+ usage: usageStateForSelection(stored, now).usage,
180
232
  lastRateLimitInfo: safe,
181
233
  probeStatus: 'ok',
182
234
  probeObservedAt: now,
183
235
  });
236
+ return result;
184
237
  }
185
238
  catch {
186
- result = 'unknown';
239
+ result = cancelled() ? 'cancelled' : 'unknown';
240
+ return result;
187
241
  }
188
242
  finally {
189
243
  if (cwd)
190
244
  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}`);
245
+ const description = context.requestId
246
+ ? `usage probe request=${context.requestId}`
247
+ : `local probe account=${poolLabel(account)}`;
248
+ poolInfo({ info: options.log }, `${description} result=${result}${result === 'cancelled' ? ' reason=account selected' : ''}`);
198
249
  }
199
250
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.5",
3
+ "version": "0.8.6-rc.1",
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.1",
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.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {