@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.
- package/dist/claude-spawner.d.ts +1 -0
- package/dist/claude-spawner.js +15 -2
- package/dist/pool-logging.js +1 -1
- package/dist/subscription-pool.d.ts +1 -0
- package/dist/subscription-pool.js +1 -1
- package/dist/subscription-runtime.d.ts +10 -1
- package/dist/subscription-runtime.js +97 -20
- package/dist/subscription-selection.d.ts +4 -1
- package/dist/subscription-selection.js +11 -1
- package/dist/subscription-usage-store.d.ts +3 -0
- package/dist/subscription-usage-store.js +18 -0
- package/dist/usage-probe-job.d.ts +17 -2
- package/dist/usage-probe-job.js +68 -17
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/claude-spawner.js
CHANGED
|
@@ -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
|
package/dist/pool-logging.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { redactStartupText } from './
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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 {};
|
package/dist/usage-probe-job.js
CHANGED
|
@@ -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
|
|
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
|
|
146
|
+
const onAbort = () => {
|
|
147
|
+
finish('cancelled');
|
|
117
148
|
kill();
|
|
149
|
+
};
|
|
150
|
+
const timer = setTimeout(() => {
|
|
118
151
|
finish('timeout');
|
|
119
|
-
|
|
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.
|
|
176
|
-
|
|
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(
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamvibe/poller",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
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",
|