@teamvibe/poller 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth-provider.d.ts +5 -0
- package/dist/auth-provider.js +19 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +3 -2
- package/dist/poller.js +103 -4
- package/dist/sqs-poller.js +26 -1
- package/package.json +1 -1
package/dist/auth-provider.d.ts
CHANGED
|
@@ -14,5 +14,10 @@ export declare function getCredentials(): AuthCredentials;
|
|
|
14
14
|
export declare function getAuthConfig(): AuthConfig;
|
|
15
15
|
export declare function getPollerId(): string | null;
|
|
16
16
|
export declare function isTokenMode(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Force an immediate credential refresh. Used when SQS returns ExpiredTokenException.
|
|
19
|
+
* Throws if refresh fails (caller should handle).
|
|
20
|
+
*/
|
|
21
|
+
export declare function forceRefresh(): Promise<void>;
|
|
17
22
|
export declare function stopRefresh(): void;
|
|
18
23
|
export {};
|
package/dist/auth-provider.js
CHANGED
|
@@ -6,6 +6,9 @@ let currentConfig = null;
|
|
|
6
6
|
let currentPollerId = null;
|
|
7
7
|
let refreshTimer = null;
|
|
8
8
|
let heartbeatTimer = null;
|
|
9
|
+
// Stored for on-demand refresh
|
|
10
|
+
let storedApiUrl = null;
|
|
11
|
+
let storedToken = null;
|
|
9
12
|
const REFRESH_INTERVAL_MS = 50 * 60 * 1000; // 50 minutes (before 1hr expiry)
|
|
10
13
|
const HEARTBEAT_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
11
14
|
async function sendHeartbeat(apiUrl, token, version) {
|
|
@@ -44,6 +47,8 @@ async function fetchCredentials(apiUrl, token) {
|
|
|
44
47
|
}
|
|
45
48
|
export async function initAuth(apiUrl, token) {
|
|
46
49
|
logger.info('Authenticating with TeamVibe API...');
|
|
50
|
+
storedApiUrl = apiUrl;
|
|
51
|
+
storedToken = token;
|
|
47
52
|
const auth = await fetchCredentials(apiUrl, token);
|
|
48
53
|
currentCredentials = auth.credentials;
|
|
49
54
|
currentConfig = auth.config;
|
|
@@ -87,6 +92,20 @@ export function getPollerId() {
|
|
|
87
92
|
export function isTokenMode() {
|
|
88
93
|
return currentCredentials !== null;
|
|
89
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Force an immediate credential refresh. Used when SQS returns ExpiredTokenException.
|
|
97
|
+
* Throws if refresh fails (caller should handle).
|
|
98
|
+
*/
|
|
99
|
+
export async function forceRefresh() {
|
|
100
|
+
if (!storedApiUrl || !storedToken) {
|
|
101
|
+
throw new Error('Cannot force refresh: auth not initialized (no stored API URL/token)');
|
|
102
|
+
}
|
|
103
|
+
logger.info('Force-refreshing credentials (token expired)...');
|
|
104
|
+
const auth = await fetchCredentials(storedApiUrl, storedToken);
|
|
105
|
+
currentCredentials = auth.credentials;
|
|
106
|
+
currentConfig = auth.config;
|
|
107
|
+
logger.info('Credentials force-refreshed successfully');
|
|
108
|
+
}
|
|
90
109
|
export function stopRefresh() {
|
|
91
110
|
if (refreshTimer) {
|
|
92
111
|
clearInterval(refreshTimer);
|
package/dist/config.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
16
16
|
IDLE_WATCHDOG_WARN_MS: z.ZodDefault<z.ZodNumber>;
|
|
17
17
|
IDLE_WATCHDOG_KILL_MS: z.ZodDefault<z.ZodNumber>;
|
|
18
18
|
NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
|
|
19
|
+
MAX_CONSECUTIVE_NET_FAILURES: z.ZodDefault<z.ZodNumber>;
|
|
19
20
|
TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
|
|
20
21
|
BRAINS_PATH: z.ZodDefault<z.ZodString>;
|
|
21
22
|
DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
|
|
@@ -45,6 +46,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
45
46
|
IDLE_WATCHDOG_WARN_MS: number;
|
|
46
47
|
IDLE_WATCHDOG_KILL_MS: number;
|
|
47
48
|
NETWORK_ERROR_KILL_MS: number;
|
|
49
|
+
MAX_CONSECUTIVE_NET_FAILURES: number;
|
|
48
50
|
TEAMVIBE_DATA_DIR: string;
|
|
49
51
|
BRAINS_PATH: string;
|
|
50
52
|
DEFAULT_BRAIN_PATH: string;
|
|
@@ -80,6 +82,7 @@ declare const configSchema: z.ZodObject<{
|
|
|
80
82
|
IDLE_WATCHDOG_WARN_MS?: number | undefined;
|
|
81
83
|
IDLE_WATCHDOG_KILL_MS?: number | undefined;
|
|
82
84
|
NETWORK_ERROR_KILL_MS?: number | undefined;
|
|
85
|
+
MAX_CONSECUTIVE_NET_FAILURES?: number | undefined;
|
|
83
86
|
TEAMVIBE_DATA_DIR?: string | undefined;
|
|
84
87
|
BRAINS_PATH?: string | undefined;
|
|
85
88
|
DEFAULT_BRAIN_PATH?: string | undefined;
|
|
@@ -112,6 +115,7 @@ export declare const config: {
|
|
|
112
115
|
IDLE_WATCHDOG_WARN_MS: number;
|
|
113
116
|
IDLE_WATCHDOG_KILL_MS: number;
|
|
114
117
|
NETWORK_ERROR_KILL_MS: number;
|
|
118
|
+
MAX_CONSECUTIVE_NET_FAILURES: number;
|
|
115
119
|
TEAMVIBE_DATA_DIR: string;
|
|
116
120
|
BRAINS_PATH: string;
|
|
117
121
|
DEFAULT_BRAIN_PATH: string;
|
package/dist/config.js
CHANGED
|
@@ -24,7 +24,8 @@ const configSchema = z.object({
|
|
|
24
24
|
LOCK_RETRY_INTERVAL_MS: z.coerce.number().default(30000), // 30 seconds
|
|
25
25
|
IDLE_WATCHDOG_WARN_MS: z.coerce.number().default(300000), // 5 minutes
|
|
26
26
|
IDLE_WATCHDOG_KILL_MS: z.coerce.number().default(600000), // 10 minutes
|
|
27
|
-
NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // 2
|
|
27
|
+
NETWORK_ERROR_KILL_MS: z.coerce.number().default(120000), // Per-session: kill stuck session after 2 min of network errors
|
|
28
|
+
MAX_CONSECUTIVE_NET_FAILURES: z.coerce.number().default(5), // Poll loop: DNS flush at N, exit(1) at 2×N consecutive failures
|
|
28
29
|
// Paths
|
|
29
30
|
TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
|
|
30
31
|
BRAINS_PATH: z.string().default(''),
|
|
@@ -43,7 +44,7 @@ const configSchema = z.object({
|
|
|
43
44
|
.transform((v) => v.toLowerCase() === 'true'),
|
|
44
45
|
BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
|
|
45
46
|
// Brain heartbeat & maintenance (cron-based)
|
|
46
|
-
HEARTBEAT_CRON: z.string().default('*/
|
|
47
|
+
HEARTBEAT_CRON: z.string().default('0 */6 * * *'), // Every 6 hours
|
|
47
48
|
MAINTENANCE_CRON: z.string().default('30 3 * * *'), // Daily at 03:30 UTC
|
|
48
49
|
MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
|
|
49
50
|
// Auto-update settings
|
package/dist/poller.js
CHANGED
|
@@ -13,6 +13,10 @@ import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocesse
|
|
|
13
13
|
import { Cron } from 'croner';
|
|
14
14
|
import { isControlMessage, handleControlMessage } from './control-handler.js';
|
|
15
15
|
import { checkForUpdate } from './auto-update.js';
|
|
16
|
+
import { exec } from 'child_process';
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
import { platform } from 'os';
|
|
19
|
+
const execAsync = promisify(exec);
|
|
16
20
|
// Track active message processing
|
|
17
21
|
const processingMessages = new Set();
|
|
18
22
|
// Poller auto-update cron (separate from brain heartbeats)
|
|
@@ -49,6 +53,38 @@ function stopAutoUpdateCron() {
|
|
|
49
53
|
autoUpdateJob = null;
|
|
50
54
|
}
|
|
51
55
|
}
|
|
56
|
+
// --- Circuit breaker ---
|
|
57
|
+
let consecutiveNetFailures = 0;
|
|
58
|
+
const NETWORK_ERROR_CODES = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED'];
|
|
59
|
+
function isNetworkError(error) {
|
|
60
|
+
if (error instanceof Error) {
|
|
61
|
+
const code = error.code || '';
|
|
62
|
+
const message = error.message || '';
|
|
63
|
+
return (NETWORK_ERROR_CODES.includes(code) ||
|
|
64
|
+
message.includes('getaddrinfo') ||
|
|
65
|
+
message.includes('socket hang up'));
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
async function attemptDnsFlush() {
|
|
70
|
+
const cmd = platform() === 'darwin'
|
|
71
|
+
? 'dscacheutil -flushcache'
|
|
72
|
+
: platform() === 'linux'
|
|
73
|
+
? 'resolvectl flush-caches'
|
|
74
|
+
: null;
|
|
75
|
+
if (!cmd) {
|
|
76
|
+
logger.warn(`Circuit breaker: DNS flush not supported on platform "${platform()}"`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
logger.warn('Circuit breaker: Attempting DNS cache flush...');
|
|
80
|
+
try {
|
|
81
|
+
await execAsync(cmd, { timeout: 5000 });
|
|
82
|
+
logger.info('Circuit breaker: DNS cache flush completed');
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
logger.warn(`Circuit breaker: DNS cache flush failed: ${err}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
52
88
|
// Track which threads have an active processing loop
|
|
53
89
|
const activeLoops = new Set();
|
|
54
90
|
async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
|
|
@@ -302,10 +338,34 @@ function deduplicateMessages(messages) {
|
|
|
302
338
|
}
|
|
303
339
|
return { unique, duplicates };
|
|
304
340
|
}
|
|
341
|
+
// Circuit breaker for consecutive auth/infrastructure failures
|
|
342
|
+
const AUTH_CIRCUIT_BREAKER_THRESHOLD = 3;
|
|
343
|
+
const AUTH_CIRCUIT_BREAKER_MAX_BACKOFF_MS = 60_000;
|
|
344
|
+
let consecutiveAuthFailures = 0;
|
|
345
|
+
function isAuthError(error) {
|
|
346
|
+
if (!(error instanceof Error))
|
|
347
|
+
return false;
|
|
348
|
+
const name = error.name ?? '';
|
|
349
|
+
const code = error.Code ?? '';
|
|
350
|
+
const message = error.message ?? '';
|
|
351
|
+
return (name === 'ExpiredTokenException' ||
|
|
352
|
+
code === 'ExpiredToken' ||
|
|
353
|
+
code === 'InvalidClientTokenId' ||
|
|
354
|
+
code === 'UnrecognizedClientException' ||
|
|
355
|
+
message.includes('security token') ||
|
|
356
|
+
message.includes('Auth failed'));
|
|
357
|
+
}
|
|
305
358
|
async function pollLoop() {
|
|
306
359
|
logger.info('Poll loop started');
|
|
307
360
|
while (!shuttingDown) {
|
|
308
361
|
try {
|
|
362
|
+
// Circuit breaker: if too many consecutive auth failures, back off
|
|
363
|
+
if (consecutiveAuthFailures >= AUTH_CIRCUIT_BREAKER_THRESHOLD) {
|
|
364
|
+
const backoffMs = Math.min(5_000 * Math.pow(2, consecutiveAuthFailures - AUTH_CIRCUIT_BREAKER_THRESHOLD), AUTH_CIRCUIT_BREAKER_MAX_BACKOFF_MS);
|
|
365
|
+
logger.warn(`[CircuitBreaker] Auth degraded (${consecutiveAuthFailures} consecutive failures), ` +
|
|
366
|
+
`backing off ${backoffMs}ms before retry...`);
|
|
367
|
+
await sleep(backoffMs);
|
|
368
|
+
}
|
|
309
369
|
if (isAtCapacity()) {
|
|
310
370
|
logger.debug(`At capacity (${getActiveProcessCount()}/${config.MAX_CONCURRENT_SESSIONS}), waiting...`);
|
|
311
371
|
await sleep(1000);
|
|
@@ -314,6 +374,12 @@ async function pollLoop() {
|
|
|
314
374
|
const availableSlots = config.MAX_CONCURRENT_SESSIONS - getActiveProcessCount();
|
|
315
375
|
logger.debug(`Polling for up to ${availableSlots} messages...`);
|
|
316
376
|
const messages = await pollMessages(availableSlots);
|
|
377
|
+
consecutiveNetFailures = 0; // Successful poll — reset circuit breaker
|
|
378
|
+
// Successful poll — reset circuit breaker
|
|
379
|
+
if (consecutiveAuthFailures > 0) {
|
|
380
|
+
logger.info(`[CircuitBreaker] Auth recovered after ${consecutiveAuthFailures} failure(s)`);
|
|
381
|
+
consecutiveAuthFailures = 0;
|
|
382
|
+
}
|
|
317
383
|
if (messages.length === 0) {
|
|
318
384
|
continue;
|
|
319
385
|
}
|
|
@@ -347,8 +413,41 @@ async function pollLoop() {
|
|
|
347
413
|
Promise.all(processPromises);
|
|
348
414
|
}
|
|
349
415
|
catch (error) {
|
|
350
|
-
|
|
351
|
-
|
|
416
|
+
if (isAuthError(error)) {
|
|
417
|
+
consecutiveAuthFailures++;
|
|
418
|
+
logger.error(`[CircuitBreaker] Auth failure #${consecutiveAuthFailures}: ${error instanceof Error ? error.message : error}`);
|
|
419
|
+
emitEvent('poller.auth_failure', '', { consecutiveFailures: consecutiveAuthFailures });
|
|
420
|
+
await sleep(5000);
|
|
421
|
+
}
|
|
422
|
+
else if (isNetworkError(error)) {
|
|
423
|
+
consecutiveNetFailures++;
|
|
424
|
+
logger.error(`Network error in poll loop (${consecutiveNetFailures}/${config.MAX_CONSECUTIVE_NET_FAILURES}):`, error);
|
|
425
|
+
if (consecutiveNetFailures === config.MAX_CONSECUTIVE_NET_FAILURES) {
|
|
426
|
+
await attemptDnsFlush();
|
|
427
|
+
}
|
|
428
|
+
if (consecutiveNetFailures >= config.MAX_CONSECUTIVE_NET_FAILURES * 2) {
|
|
429
|
+
logger.error(`Circuit breaker triggered: ${consecutiveNetFailures} consecutive network failures. Exiting for restart.`);
|
|
430
|
+
emitEvent('poller.circuit_breaker', '', {
|
|
431
|
+
reason: 'sustained_network_failure',
|
|
432
|
+
count: consecutiveNetFailures,
|
|
433
|
+
});
|
|
434
|
+
// Hard timeout on shutdown — network may be down, don't hang
|
|
435
|
+
await Promise.race([
|
|
436
|
+
shutdown('CIRCUIT_BREAKER', 1),
|
|
437
|
+
sleep(15000),
|
|
438
|
+
]);
|
|
439
|
+
process.exit(1); // Fallback if shutdown didn't call process.exit (e.g. timeout)
|
|
440
|
+
}
|
|
441
|
+
// Exponential backoff: 5s, 10s, 20s, 40s... capped at 60s
|
|
442
|
+
const backoffMs = Math.min(5000 * Math.pow(2, consecutiveNetFailures - 1), 60000);
|
|
443
|
+
await sleep(backoffMs);
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
// Non-network, non-auth errors — reset auth circuit breaker (infra is reachable)
|
|
447
|
+
consecutiveAuthFailures = 0;
|
|
448
|
+
logger.error('Error in poll loop:', error);
|
|
449
|
+
await sleep(5000);
|
|
450
|
+
}
|
|
352
451
|
}
|
|
353
452
|
}
|
|
354
453
|
}
|
|
@@ -357,7 +456,7 @@ function sleep(ms) {
|
|
|
357
456
|
}
|
|
358
457
|
// Graceful shutdown
|
|
359
458
|
let shuttingDown = false;
|
|
360
|
-
async function shutdown(signal) {
|
|
459
|
+
async function shutdown(signal, exitCode = 0) {
|
|
361
460
|
if (shuttingDown)
|
|
362
461
|
return;
|
|
363
462
|
shuttingDown = true;
|
|
@@ -382,7 +481,7 @@ async function shutdown(signal) {
|
|
|
382
481
|
// Final flush after all processes stopped — captures errors emitted during shutdown
|
|
383
482
|
await flushEventQueue();
|
|
384
483
|
logger.info('Shutdown complete');
|
|
385
|
-
process.exit(
|
|
484
|
+
process.exit(exitCode);
|
|
386
485
|
}
|
|
387
486
|
export async function startPoller() {
|
|
388
487
|
logger.info('TeamVibe Poller starting...');
|
package/dist/sqs-poller.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand, ChangeMessageVisibilityCommand, SendMessageCommand, } from '@aws-sdk/client-sqs';
|
|
2
2
|
import { config } from './config.js';
|
|
3
3
|
import { logger } from './logger.js';
|
|
4
|
-
import { isTokenMode, getCredentials, getAuthConfig } from './auth-provider.js';
|
|
4
|
+
import { isTokenMode, getCredentials, getAuthConfig, forceRefresh } from './auth-provider.js';
|
|
5
5
|
import { emitError } from './event-queue.js';
|
|
6
|
+
function isExpiredTokenError(error) {
|
|
7
|
+
if (!(error instanceof Error))
|
|
8
|
+
return false;
|
|
9
|
+
const name = error.name ?? '';
|
|
10
|
+
const code = error.Code ?? '';
|
|
11
|
+
return name === 'ExpiredTokenException' || code === 'ExpiredToken';
|
|
12
|
+
}
|
|
6
13
|
function createSqsClient() {
|
|
7
14
|
if (isTokenMode()) {
|
|
8
15
|
const creds = getCredentials();
|
|
@@ -42,6 +49,24 @@ export async function pollMessages(maxMessages = 10) {
|
|
|
42
49
|
return response.Messages.map((msg) => parseMessage(msg)).filter((m) => m !== null);
|
|
43
50
|
}
|
|
44
51
|
catch (error) {
|
|
52
|
+
// Expired AWS STS token — force-refresh credentials and retry once
|
|
53
|
+
if (isTokenMode() && isExpiredTokenError(error)) {
|
|
54
|
+
logger.warn('SQS poll failed with ExpiredToken, attempting credential refresh...');
|
|
55
|
+
await forceRefresh(); // throws if refresh itself fails
|
|
56
|
+
// Retry poll with fresh credentials
|
|
57
|
+
const retryResponse = await createSqsClient().send(new ReceiveMessageCommand({
|
|
58
|
+
QueueUrl: getQueueUrl(),
|
|
59
|
+
MaxNumberOfMessages: Math.min(maxMessages, 10),
|
|
60
|
+
WaitTimeSeconds: config.POLL_WAIT_TIME_SECONDS,
|
|
61
|
+
VisibilityTimeout: config.VISIBILITY_TIMEOUT_SECONDS,
|
|
62
|
+
MessageAttributeNames: ['All'],
|
|
63
|
+
}));
|
|
64
|
+
if (!retryResponse.Messages || retryResponse.Messages.length === 0) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
logger.info(`Received ${retryResponse.Messages.length} message(s) after credential refresh`);
|
|
68
|
+
return retryResponse.Messages.map((msg) => parseMessage(msg)).filter((m) => m !== null);
|
|
69
|
+
}
|
|
45
70
|
logger.error('Error polling SQS:', error);
|
|
46
71
|
emitError('sqs', `Error polling SQS: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
72
|
throw error;
|