@teamvibe/poller 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,23 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { execSync } from 'child_process';
4
- import { PLIST_PATH, SERVICE_LABEL, TEAMVIBE_DIR, LOGS_DIR } from './plist.js';
4
+ import { PLIST_PATH, SERVICE_LABEL, TEAMVIBE_DIR, LOGS_DIR, isContainerRuntime } from './plist.js';
5
+ function ok(name, message) {
6
+ return { name, status: 'ok', message };
7
+ }
8
+ function fail(name, message) {
9
+ return { name, status: 'fail', message };
10
+ }
11
+ function skipped(name, reason) {
12
+ return { name, status: 'skipped', message: `Skipped (${reason})` };
13
+ }
5
14
  function check(name, fn) {
6
15
  try {
7
16
  const result = fn();
8
- if (result === true) {
9
- return { name, ok: true, message: 'OK' };
10
- }
11
- return { name, ok: true, message: result };
17
+ return ok(name, result === true ? 'OK' : result);
12
18
  }
13
19
  catch (error) {
14
- return { name, ok: false, message: error instanceof Error ? error.message : String(error) };
20
+ return fail(name, error instanceof Error ? error.message : String(error));
15
21
  }
16
22
  }
17
23
  function checkNodeVersion() {
@@ -24,7 +30,9 @@ function checkNodeVersion() {
24
30
  return `${version} (>= v18)`;
25
31
  });
26
32
  }
27
- function checkTeamVibeDir() {
33
+ function checkTeamVibeDir(container) {
34
+ if (container)
35
+ return skipped('TeamVibe directory', 'container');
28
36
  return check('TeamVibe directory', () => {
29
37
  if (!fs.existsSync(TEAMVIBE_DIR)) {
30
38
  throw new Error(`${TEAMVIBE_DIR} does not exist. Run 'poller install' first.`);
@@ -32,7 +40,16 @@ function checkTeamVibeDir() {
32
40
  return TEAMVIBE_DIR;
33
41
  });
34
42
  }
35
- function checkEnvFile() {
43
+ function checkEnvFile(container) {
44
+ if (container) {
45
+ // In containers, check process.env instead of .env file
46
+ const hasToken = !!process.env['TEAMVIBE_POLLER_TOKEN'];
47
+ const hasDirect = !!(process.env['SQS_QUEUE_URL'] && process.env['SESSIONS_TABLE']);
48
+ if (!hasToken && !hasDirect) {
49
+ return fail('Environment config', 'Missing auth env vars (TEAMVIBE_POLLER_TOKEN or SQS_QUEUE_URL + SESSIONS_TABLE)');
50
+ }
51
+ return ok('Environment config', hasToken ? 'Token mode (env vars)' : 'Direct mode (env vars)');
52
+ }
36
53
  return check('Environment config', () => {
37
54
  const envPath = path.join(TEAMVIBE_DIR, '.env');
38
55
  if (!fs.existsSync(envPath)) {
@@ -48,10 +65,9 @@ function checkEnvFile() {
48
65
  });
49
66
  }
50
67
  function checkLaunchdPlist() {
68
+ if (process.platform !== 'darwin')
69
+ return skipped('Launchd plist', 'not macOS');
51
70
  return check('Launchd plist', () => {
52
- if (process.platform !== 'darwin') {
53
- return 'Skipped (not macOS)';
54
- }
55
71
  if (!fs.existsSync(PLIST_PATH)) {
56
72
  throw new Error(`Plist not found at ${PLIST_PATH}. Run 'poller install'.`);
57
73
  }
@@ -66,10 +82,9 @@ function checkLaunchdPlist() {
66
82
  });
67
83
  }
68
84
  function checkServiceRunning() {
85
+ if (process.platform !== 'darwin')
86
+ return skipped('Service status', 'not macOS');
69
87
  return check('Service status', () => {
70
- if (process.platform !== 'darwin') {
71
- return 'Skipped (not macOS)';
72
- }
73
88
  try {
74
89
  const output = execSync(`launchctl list | grep ${SERVICE_LABEL}`, { encoding: 'utf-8' });
75
90
  if (output.trim()) {
@@ -91,7 +106,9 @@ function checkServiceRunning() {
91
106
  }
92
107
  });
93
108
  }
94
- function checkLogsDir() {
109
+ function checkLogsDir(container) {
110
+ if (container)
111
+ return skipped('Logs directory', 'container uses stdout');
95
112
  return check('Logs directory', () => {
96
113
  if (!fs.existsSync(LOGS_DIR)) {
97
114
  throw new Error(`${LOGS_DIR} does not exist`);
@@ -140,48 +157,55 @@ function checkBrainsDir() {
140
157
  return `${brains.length} brain(s) cloned`;
141
158
  });
142
159
  }
143
- function checkDiskSpace() {
160
+ function checkDiskSpace(container) {
144
161
  return check('Disk space', () => {
145
- try {
146
- const output = execSync(`df -h "${TEAMVIBE_DIR}" | tail -1`, { encoding: 'utf-8' }).trim();
147
- const parts = output.split(/\s+/);
148
- const available = parts[3];
149
- const capacityStr = parts[4]?.replace('%', '');
150
- const capacity = parseInt(capacityStr || '0', 10);
151
- if (capacity > 95) {
152
- throw new Error(`Disk ${capacity}% full — only ${available} available`);
153
- }
154
- if (capacity > 85) {
155
- return `Warning: ${capacity}% used (${available} available)`;
156
- }
157
- return `${available} available (${capacity}% used)`;
158
- }
159
- catch (error) {
160
- if (error instanceof Error && error.message.includes('Disk'))
161
- throw error;
162
- throw new Error('Could not check disk space');
163
- }
162
+ // Use TEAMVIBE_DIR for installed setups, fall back to / for containers or when dir doesn't exist
163
+ const dfTarget = !container && fs.existsSync(TEAMVIBE_DIR) ? TEAMVIBE_DIR : '/';
164
+ const output = execSync(`df -h "${dfTarget}" | tail -1`, { encoding: 'utf-8' }).trim();
165
+ const parts = output.split(/\s+/);
166
+ const available = parts[3];
167
+ const capacityStr = parts[4]?.replace('%', '');
168
+ const capacity = parseInt(capacityStr || '0', 10);
169
+ if (capacity > 95) {
170
+ throw new Error(`Disk ${capacity}% full — only ${available} available`);
171
+ }
172
+ if (capacity > 85) {
173
+ return `Warning: ${capacity}% used (${available} available)`;
174
+ }
175
+ return `${available} available (${capacity}% used)`;
164
176
  });
165
177
  }
166
178
  export async function doctor() {
167
- console.log('TeamVibe Poller - Health Check\n');
179
+ const container = isContainerRuntime();
180
+ const mode = container ? 'container' : 'installed';
181
+ console.log(`TeamVibe Poller - Health Check (${mode})\n`);
168
182
  const results = [
169
183
  checkNodeVersion(),
170
- checkTeamVibeDir(),
171
- checkEnvFile(),
184
+ checkTeamVibeDir(container),
185
+ checkEnvFile(container),
172
186
  checkLaunchdPlist(),
173
187
  checkServiceRunning(),
174
- checkLogsDir(),
188
+ checkLogsDir(container),
175
189
  checkClaudeCli(),
176
190
  checkBrainsDir(),
177
- checkDiskSpace(),
191
+ checkDiskSpace(container),
178
192
  ];
179
193
  let hasFailure = false;
180
194
  for (const result of results) {
181
- const icon = result.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
195
+ let icon;
196
+ switch (result.status) {
197
+ case 'ok':
198
+ icon = '\x1b[32m✓\x1b[0m';
199
+ break;
200
+ case 'skipped':
201
+ icon = '\x1b[33m–\x1b[0m';
202
+ break;
203
+ case 'fail':
204
+ icon = '\x1b[31m✗\x1b[0m';
205
+ hasFailure = true;
206
+ break;
207
+ }
182
208
  console.log(` ${icon} ${result.name}: ${result.message}`);
183
- if (!result.ok)
184
- hasFailure = true;
185
209
  }
186
210
  console.log('');
187
211
  if (hasFailure) {
@@ -2,4 +2,9 @@ export declare const SERVICE_LABEL = "ai.teamvibe.poller";
2
2
  export declare const PLIST_PATH: string;
3
3
  export declare const TEAMVIBE_DIR: string;
4
4
  export declare const LOGS_DIR: string;
5
+ /**
6
+ * Detect container runtime (Railway, Docker, etc.) where `poller install` was never run.
7
+ * In containers, env vars are injected directly — no ~/.teamvibe directory exists.
8
+ */
9
+ export declare function isContainerRuntime(): boolean;
5
10
  export declare function generatePlist(nodePath: string, pollerPath: string, envVars?: Record<string, string>): string;
package/dist/cli/plist.js CHANGED
@@ -1,9 +1,27 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
2
3
  import * as os from 'os';
3
4
  export const SERVICE_LABEL = 'ai.teamvibe.poller';
4
5
  export const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${SERVICE_LABEL}.plist`);
5
6
  export const TEAMVIBE_DIR = path.join(os.homedir(), '.teamvibe');
6
7
  export const LOGS_DIR = path.join(TEAMVIBE_DIR, 'logs');
8
+ /**
9
+ * Detect container runtime (Railway, Docker, etc.) where `poller install` was never run.
10
+ * In containers, env vars are injected directly — no ~/.teamvibe directory exists.
11
+ */
12
+ export function isContainerRuntime() {
13
+ // Railway-specific env vars
14
+ if (process.env['RAILWAY_ENVIRONMENT'] || process.env['RAILWAY_SERVICE_ID'])
15
+ return true;
16
+ // Generic container signals
17
+ if (process.env['CONTAINER'] === 'true' || process.env['DOCKER'] === 'true')
18
+ return true;
19
+ // Fallback heuristic: has auth env vars but no ~/.teamvibe directory
20
+ const hasEnvAuth = !!(process.env['TEAMVIBE_POLLER_TOKEN'] || (process.env['SQS_QUEUE_URL'] && process.env['SESSIONS_TABLE']));
21
+ if (hasEnvAuth && !fs.existsSync(TEAMVIBE_DIR))
22
+ return true;
23
+ return false;
24
+ }
7
25
  export function generatePlist(nodePath, pollerPath, envVars) {
8
26
  let envSection = '';
9
27
  if (envVars && Object.keys(envVars).length > 0) {
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 minutes of continuous network errors → kill
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(''),
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) {
@@ -314,6 +350,7 @@ async function pollLoop() {
314
350
  const availableSlots = config.MAX_CONCURRENT_SESSIONS - getActiveProcessCount();
315
351
  logger.debug(`Polling for up to ${availableSlots} messages...`);
316
352
  const messages = await pollMessages(availableSlots);
353
+ consecutiveNetFailures = 0; // Successful poll — reset circuit breaker
317
354
  if (messages.length === 0) {
318
355
  continue;
319
356
  }
@@ -347,8 +384,34 @@ async function pollLoop() {
347
384
  Promise.all(processPromises);
348
385
  }
349
386
  catch (error) {
350
- logger.error('Error in poll loop:', error);
351
- await sleep(5000);
387
+ if (isNetworkError(error)) {
388
+ consecutiveNetFailures++;
389
+ logger.error(`Network error in poll loop (${consecutiveNetFailures}/${config.MAX_CONSECUTIVE_NET_FAILURES}):`, error);
390
+ if (consecutiveNetFailures === config.MAX_CONSECUTIVE_NET_FAILURES) {
391
+ await attemptDnsFlush();
392
+ }
393
+ if (consecutiveNetFailures >= config.MAX_CONSECUTIVE_NET_FAILURES * 2) {
394
+ logger.error(`Circuit breaker triggered: ${consecutiveNetFailures} consecutive network failures. Exiting for restart.`);
395
+ emitEvent('poller.circuit_breaker', '', {
396
+ reason: 'sustained_network_failure',
397
+ count: consecutiveNetFailures,
398
+ });
399
+ // Hard timeout on shutdown — network may be down, don't hang
400
+ await Promise.race([
401
+ shutdown('CIRCUIT_BREAKER', 1),
402
+ sleep(15000),
403
+ ]);
404
+ process.exit(1); // Fallback if shutdown didn't call process.exit (e.g. timeout)
405
+ }
406
+ // Exponential backoff: 5s, 10s, 20s, 40s... capped at 60s
407
+ const backoffMs = Math.min(5000 * Math.pow(2, consecutiveNetFailures - 1), 60000);
408
+ await sleep(backoffMs);
409
+ }
410
+ else {
411
+ // Non-network errors don't affect the circuit breaker counter
412
+ logger.error('Error in poll loop:', error);
413
+ await sleep(5000);
414
+ }
352
415
  }
353
416
  }
354
417
  }
@@ -357,7 +420,7 @@ function sleep(ms) {
357
420
  }
358
421
  // Graceful shutdown
359
422
  let shuttingDown = false;
360
- async function shutdown(signal) {
423
+ async function shutdown(signal, exitCode = 0) {
361
424
  if (shuttingDown)
362
425
  return;
363
426
  shuttingDown = true;
@@ -382,7 +445,7 @@ async function shutdown(signal) {
382
445
  // Final flush after all processes stopped — captures errors emitted during shutdown
383
446
  await flushEventQueue();
384
447
  logger.info('Shutdown complete');
385
- process.exit(0);
448
+ process.exit(exitCode);
386
449
  }
387
450
  export async function startPoller() {
388
451
  logger.info('TeamVibe Poller starting...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {