@teamvibe/poller 0.1.67 → 0.1.69

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Compare two semver versions. Returns:
3
+ * - 'patch' if only patch differs (1.2.3 → 1.2.4)
4
+ * - 'minor' if minor differs (1.2.3 → 1.3.0)
5
+ * - 'major' if major differs (1.2.3 → 2.0.0)
6
+ * - null if same or current is newer
7
+ */
8
+ export declare function classifyUpdate(current: string, latest: string): 'patch' | 'minor' | 'major' | null;
9
+ /**
10
+ * Check for updates and apply if within policy.
11
+ * Called by the cron job defined in poller.ts.
12
+ *
13
+ * Returns true if an update was applied and poller should restart (exit 0).
14
+ */
15
+ export declare function checkForUpdate(): Promise<boolean>;
@@ -0,0 +1,151 @@
1
+ import { execSync } from 'child_process';
2
+ import { config, POLLER_VERSION } from './config.js';
3
+ import { logger } from './logger.js';
4
+ import { emitEvent } from './event-queue.js';
5
+ import { doctor } from './cli/doctor.js';
6
+ const UPDATE_TIMEOUT_MS = 120_000; // 2 minutes
7
+ const MAX_CONSECUTIVE_FAILURES = 3;
8
+ let consecutiveFailures = 0;
9
+ /**
10
+ * Compare two semver versions. Returns:
11
+ * - 'patch' if only patch differs (1.2.3 → 1.2.4)
12
+ * - 'minor' if minor differs (1.2.3 → 1.3.0)
13
+ * - 'major' if major differs (1.2.3 → 2.0.0)
14
+ * - null if same or current is newer
15
+ */
16
+ export function classifyUpdate(current, latest) {
17
+ const [curMajor, curMinor, curPatch] = current.split('.').map(Number);
18
+ const [latMajor, latMinor, latPatch] = latest.split('.').map(Number);
19
+ if (latMajor > curMajor)
20
+ return 'major';
21
+ if (latMajor < curMajor)
22
+ return null;
23
+ if (latMinor > curMinor)
24
+ return 'minor';
25
+ if (latMinor < curMinor)
26
+ return null;
27
+ if (latPatch > curPatch)
28
+ return 'patch';
29
+ return null;
30
+ }
31
+ /**
32
+ * Fetch the latest version from npm registry.
33
+ */
34
+ function getLatestVersion() {
35
+ try {
36
+ const output = execSync('npm view @teamvibe/poller version', {
37
+ encoding: 'utf-8',
38
+ timeout: 15_000,
39
+ stdio: ['pipe', 'pipe', 'pipe'],
40
+ });
41
+ return output.trim();
42
+ }
43
+ catch (error) {
44
+ logger.warn(`[AutoUpdate] Failed to check npm registry: ${error instanceof Error ? error.message : error}`);
45
+ return null;
46
+ }
47
+ }
48
+ /**
49
+ * Check for updates and apply if within policy.
50
+ * Called by the cron job defined in poller.ts.
51
+ *
52
+ * Returns true if an update was applied and poller should restart (exit 0).
53
+ */
54
+ export async function checkForUpdate() {
55
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
56
+ logger.warn(`[AutoUpdate] Disabled — ${consecutiveFailures} consecutive failures`);
57
+ emitEvent('poller.autoupdate.disabled', 'system', {
58
+ reason: 'max-failures-reached',
59
+ failures: consecutiveFailures,
60
+ currentVersion: POLLER_VERSION,
61
+ });
62
+ return false;
63
+ }
64
+ logger.info(`[AutoUpdate] Checking for updates (current: ${POLLER_VERSION}, policy: ${config.AUTO_UPDATE_POLICY})`);
65
+ // Check npm registry for latest version
66
+ const latestVersion = getLatestVersion();
67
+ if (!latestVersion) {
68
+ return false;
69
+ }
70
+ const updateType = classifyUpdate(POLLER_VERSION, latestVersion);
71
+ if (!updateType) {
72
+ logger.info(`[AutoUpdate] Already on latest version (${POLLER_VERSION})`);
73
+ return false;
74
+ }
75
+ // Check if update type is within policy
76
+ const policyAllows = config.AUTO_UPDATE_POLICY === 'minor'
77
+ ? updateType === 'patch' || updateType === 'minor'
78
+ : updateType === 'patch'; // policy === 'patch'
79
+ if (!policyAllows) {
80
+ logger.info(`[AutoUpdate] ${updateType} update available (${POLLER_VERSION} → ${latestVersion}) but policy is "${config.AUTO_UPDATE_POLICY}", skipping`);
81
+ return false;
82
+ }
83
+ logger.info(`[AutoUpdate] ${updateType} update available: ${POLLER_VERSION} → ${latestVersion}`);
84
+ // Step 1: Pre-check
85
+ logger.info('[AutoUpdate] Running pre-update health check...');
86
+ const preCheckOk = await doctor();
87
+ if (!preCheckOk) {
88
+ logger.warn('[AutoUpdate] Pre-check failed, skipping update');
89
+ consecutiveFailures++;
90
+ emitEvent('poller.autoupdate.failed', 'system', {
91
+ reason: 'pre-check-failed',
92
+ currentVersion: POLLER_VERSION,
93
+ targetVersion: latestVersion,
94
+ });
95
+ return false;
96
+ }
97
+ // Step 2: Install
98
+ logger.info(`[AutoUpdate] Installing @teamvibe/poller@${latestVersion}...`);
99
+ try {
100
+ execSync(`npm install -g @teamvibe/poller@${latestVersion}`, {
101
+ timeout: UPDATE_TIMEOUT_MS,
102
+ stdio: 'pipe',
103
+ encoding: 'utf-8',
104
+ });
105
+ }
106
+ catch (error) {
107
+ const errorMsg = error instanceof Error ? error.message : String(error);
108
+ logger.error(`[AutoUpdate] Install failed: ${errorMsg}`);
109
+ consecutiveFailures++;
110
+ emitEvent('poller.autoupdate.failed', 'system', {
111
+ reason: 'install-failed',
112
+ currentVersion: POLLER_VERSION,
113
+ targetVersion: latestVersion,
114
+ error: errorMsg,
115
+ });
116
+ return false;
117
+ }
118
+ // Step 3: Post-check
119
+ logger.info('[AutoUpdate] Running post-update health check...');
120
+ const postCheckOk = await doctor();
121
+ if (!postCheckOk) {
122
+ logger.warn(`[AutoUpdate] Post-check failed, rolling back to ${POLLER_VERSION}...`);
123
+ try {
124
+ execSync(`npm install -g @teamvibe/poller@${POLLER_VERSION}`, {
125
+ timeout: UPDATE_TIMEOUT_MS,
126
+ stdio: 'pipe',
127
+ encoding: 'utf-8',
128
+ });
129
+ logger.info(`[AutoUpdate] Rolled back to ${POLLER_VERSION}`);
130
+ }
131
+ catch (rollbackError) {
132
+ logger.error(`[AutoUpdate] CRITICAL: Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : rollbackError}`);
133
+ }
134
+ consecutiveFailures++;
135
+ emitEvent('poller.autoupdate.rolledback', 'system', {
136
+ currentVersion: POLLER_VERSION,
137
+ targetVersion: latestVersion,
138
+ reason: 'post-check-failed',
139
+ });
140
+ return false;
141
+ }
142
+ // Success
143
+ consecutiveFailures = 0;
144
+ logger.info(`[AutoUpdate] Successfully updated ${POLLER_VERSION} → ${latestVersion}. Restart required.`);
145
+ emitEvent('poller.autoupdate.success', 'system', {
146
+ previousVersion: POLLER_VERSION,
147
+ newVersion: latestVersion,
148
+ updateType,
149
+ });
150
+ return true;
151
+ }
@@ -13,5 +13,10 @@ export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: S
13
13
  newSessionId?: string;
14
14
  }>;
15
15
  export declare function getActiveProcessCount(): number;
16
+ /**
17
+ * Kill a zombie process for a given thread ID.
18
+ * Returns true if a process was found and killed.
19
+ */
20
+ export declare function killZombieForThread(threadId: string): boolean;
16
21
  export declare function isAtCapacity(): boolean;
17
22
  export declare function killAllActiveProcesses(): Promise<void>;
@@ -128,6 +128,14 @@ export function buildPrompt(msg, resumeHint) {
128
128
  : `Channel ${slack.channel}`;
129
129
  prompt += `**Channel:** ${channelType}\n`;
130
130
  }
131
+ // Inject human-readable timestamp from Slack message ts
132
+ if (msg.response_context.slack?.message_ts) {
133
+ const msgDate = new Date(parseFloat(msg.response_context.slack.message_ts) * 1000);
134
+ const dayOfWeek = msgDate.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' });
135
+ const dateStr = msgDate.toISOString().slice(0, 10);
136
+ const timeStr = msgDate.toISOString().slice(11, 16);
137
+ prompt += `**Sent at:** ${dayOfWeek}, ${dateStr} ${timeStr} UTC\n`;
138
+ }
131
139
  prompt += `\n**Message:**\n${msg.text}`;
132
140
  if (msg.attachments.length > 0) {
133
141
  prompt += '\n\n**Attachments:**\n';
@@ -333,8 +341,9 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
333
341
  },
334
342
  stdio: ['pipe', 'pipe', 'pipe'],
335
343
  });
336
- // Track active process
344
+ // Track active process by message ID and thread ID
337
345
  activeProcesses.set(msg.id, proc);
346
+ activeProcessesByThread.set(msg.thread_id, proc);
338
347
  proc.stdin?.write(prompt);
339
348
  proc.stdin?.end();
340
349
  let stdout = '';
@@ -342,6 +351,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
342
351
  let stdoutBuffer = '';
343
352
  let metrics;
344
353
  let lastActivityTime = Date.now();
354
+ let networkErrorStart = null; // timestamp when continuous network errors began
345
355
  proc.stdout.on('data', (data) => {
346
356
  const chunk = data.toString();
347
357
  stdout += chunk;
@@ -406,10 +416,21 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
406
416
  }
407
417
  }
408
418
  });
419
+ const NETWORK_ERROR_PATTERNS = /ETIMEDOUT|ECONNRESET|ENOTFOUND|ECONNREFUSED|fetch failed|getaddrinfo/i;
409
420
  proc.stderr.on('data', (data) => {
410
421
  const chunk = data.toString();
411
422
  stderr += chunk;
412
423
  sessionLog.claude('stderr', chunk.trimEnd());
424
+ // Track continuous network errors for early kill
425
+ if (NETWORK_ERROR_PATTERNS.test(chunk)) {
426
+ if (networkErrorStart === null) {
427
+ networkErrorStart = Date.now();
428
+ }
429
+ }
430
+ else {
431
+ // Non-network stderr output resets the counter
432
+ networkErrorStart = null;
433
+ }
413
434
  });
414
435
  let timedOut = false;
415
436
  const timeoutMinutes = Math.round(config.CLAUDE_TIMEOUT_MS / 60000);
@@ -427,6 +448,21 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
427
448
  let idleWarned = false;
428
449
  const idleWatchdog = setInterval(() => {
429
450
  const idleMs = Date.now() - lastActivityTime;
451
+ // Network error watchdog: kill faster if seeing sustained network errors with no productive stdout
452
+ if (networkErrorStart !== null) {
453
+ const networkErrorDuration = Date.now() - networkErrorStart;
454
+ if (networkErrorDuration > config.NETWORK_ERROR_KILL_MS) {
455
+ const errorSec = Math.round(networkErrorDuration / 1000);
456
+ logger.error(`Claude Code has sustained network errors for ${errorSec}s, killing process`);
457
+ emitError('session', `Network errors for ${errorSec}s — killing process (likely network outage)`, {
458
+ context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
459
+ channelId: msg.teamvibe.channelId,
460
+ });
461
+ proc.kill('SIGTERM');
462
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
463
+ return;
464
+ }
465
+ }
430
466
  if (idleMs > config.IDLE_WATCHDOG_KILL_MS) {
431
467
  const idleMin = Math.round(idleMs / 60000);
432
468
  logger.error(`Claude Code idle for ${idleMin} minutes, killing process`);
@@ -452,6 +488,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
452
488
  clearTimeout(timeout);
453
489
  clearInterval(idleWatchdog);
454
490
  activeProcesses.delete(msg.id);
491
+ activeProcessesByThread.delete(msg.thread_id);
455
492
  const result = {
456
493
  success: code === 0,
457
494
  output: stdout,
@@ -470,6 +507,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
470
507
  clearTimeout(timeout);
471
508
  clearInterval(idleWatchdog);
472
509
  activeProcesses.delete(msg.id);
510
+ activeProcessesByThread.delete(msg.thread_id);
473
511
  emitError('session', `Claude Code process spawn error: ${error.message}`, {
474
512
  channelId: msg.teamvibe.channelId,
475
513
  context: { threadId: msg.thread_id },
@@ -491,8 +529,15 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
491
529
  sessionLog.info('Resume failed, retrying as fresh session (session files may have been lost)');
492
530
  retryResult = await runClaudeCode(msg, sessionLog, cwd, sessionId, true, lastMessageTs, onMessageSent, typingIndicator);
493
531
  }
494
- // If session ID is "already in use" (stale lock file), retry with a fresh session ID
532
+ // If session ID is "already in use" (stale lock file), kill any zombie and retry with fresh ID
495
533
  if (!retryResult.success && sessionId && retryResult.error?.includes('already in use')) {
534
+ // Kill zombie process for this thread if still alive
535
+ const killed = killZombieForThread(msg.thread_id);
536
+ if (killed) {
537
+ sessionLog.info('Killed zombie process before fresh session retry');
538
+ // Give the process a moment to fully exit and release lock files
539
+ await new Promise((resolve) => setTimeout(resolve, 2000));
540
+ }
496
541
  sessionLog.info('Session ID already in use (stale lock), retrying with fresh session ID');
497
542
  const { randomUUID } = await import('crypto');
498
543
  const freshId = randomUUID();
@@ -502,9 +547,30 @@ export async function spawnClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMe
502
547
  return retryResult;
503
548
  }
504
549
  const activeProcesses = new Map();
550
+ const activeProcessesByThread = new Map();
505
551
  export function getActiveProcessCount() {
506
552
  return activeProcesses.size;
507
553
  }
554
+ /**
555
+ * Kill a zombie process for a given thread ID.
556
+ * Returns true if a process was found and killed.
557
+ */
558
+ export function killZombieForThread(threadId) {
559
+ const proc = activeProcessesByThread.get(threadId);
560
+ if (!proc || proc.exitCode !== null) {
561
+ activeProcessesByThread.delete(threadId);
562
+ return false;
563
+ }
564
+ logger.warn(`Killing zombie process for thread ${threadId} (pid: ${proc.pid})`);
565
+ emitError('session', `Killing zombie process for thread ${threadId}`, { severity: 'warn' });
566
+ proc.kill('SIGTERM');
567
+ setTimeout(() => {
568
+ if (proc.exitCode === null) {
569
+ proc.kill('SIGKILL');
570
+ }
571
+ }, 5000);
572
+ return true;
573
+ }
508
574
  export function isAtCapacity() {
509
575
  return activeProcesses.size >= config.MAX_CONCURRENT_SESSIONS;
510
576
  }
@@ -5,6 +5,7 @@ import { update } from './update.js';
5
5
  import { logs } from './logs.js';
6
6
  import { start, stop, restart, status } from './service.js';
7
7
  import { reset } from './reset.js';
8
+ import { doctor } from './doctor.js';
8
9
  function showHelp() {
9
10
  console.log(`TeamVibe Poller
10
11
 
@@ -16,6 +17,7 @@ Commands:
16
17
  Flags: --token <token> --claude-token <token> [--api-url <url>] [--max-concurrent <n>]
17
18
  uninstall Remove the launchd service
18
19
  update Update to the latest version and restart
20
+ doctor Run health checks (verify installation & configuration)
19
21
  start Start the installed service
20
22
  stop Stop the installed service
21
23
  restart Restart the installed service
@@ -57,6 +59,11 @@ export async function handleCommand(command) {
57
59
  case 'restart':
58
60
  restart();
59
61
  break;
62
+ case 'doctor': {
63
+ const healthy = await doctor();
64
+ process.exit(healthy ? 0 : 1);
65
+ break;
66
+ }
60
67
  case 'reset':
61
68
  await reset();
62
69
  break;
@@ -0,0 +1 @@
1
+ export declare function doctor(): Promise<boolean>;
@@ -0,0 +1,195 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { PLIST_PATH, SERVICE_LABEL, TEAMVIBE_DIR, LOGS_DIR } from './plist.js';
5
+ function check(name, fn) {
6
+ try {
7
+ const result = fn();
8
+ if (result === true) {
9
+ return { name, ok: true, message: 'OK' };
10
+ }
11
+ return { name, ok: true, message: result };
12
+ }
13
+ catch (error) {
14
+ return { name, ok: false, message: error instanceof Error ? error.message : String(error) };
15
+ }
16
+ }
17
+ function checkNodeVersion() {
18
+ return check('Node.js version', () => {
19
+ const version = process.version;
20
+ const major = parseInt(version.slice(1).split('.')[0], 10);
21
+ if (major < 18) {
22
+ throw new Error(`Node.js ${version} is below minimum (v18). Please upgrade.`);
23
+ }
24
+ return `${version} (>= v18)`;
25
+ });
26
+ }
27
+ function checkTeamVibeDir() {
28
+ return check('TeamVibe directory', () => {
29
+ if (!fs.existsSync(TEAMVIBE_DIR)) {
30
+ throw new Error(`${TEAMVIBE_DIR} does not exist. Run 'poller install' first.`);
31
+ }
32
+ return TEAMVIBE_DIR;
33
+ });
34
+ }
35
+ function checkEnvFile() {
36
+ return check('Environment config', () => {
37
+ const envPath = path.join(TEAMVIBE_DIR, '.env');
38
+ if (!fs.existsSync(envPath)) {
39
+ throw new Error(`${envPath} not found. Run 'poller install' to configure.`);
40
+ }
41
+ const content = fs.readFileSync(envPath, 'utf-8');
42
+ const hasToken = content.includes('TEAMVIBE_POLLER_TOKEN');
43
+ const hasDirect = content.includes('SQS_QUEUE_URL') && content.includes('SESSIONS_TABLE');
44
+ if (!hasToken && !hasDirect) {
45
+ throw new Error('Missing auth configuration (TEAMVIBE_POLLER_TOKEN or SQS_QUEUE_URL + SESSIONS_TABLE)');
46
+ }
47
+ return hasToken ? 'Token mode configured' : 'Direct mode configured';
48
+ });
49
+ }
50
+ function checkLaunchdPlist() {
51
+ return check('Launchd plist', () => {
52
+ if (process.platform !== 'darwin') {
53
+ return 'Skipped (not macOS)';
54
+ }
55
+ if (!fs.existsSync(PLIST_PATH)) {
56
+ throw new Error(`Plist not found at ${PLIST_PATH}. Run 'poller install'.`);
57
+ }
58
+ const content = fs.readFileSync(PLIST_PATH, 'utf-8');
59
+ if (!content.includes('<key>KeepAlive</key>')) {
60
+ throw new Error('KeepAlive not set in plist — poller will not auto-restart on exit');
61
+ }
62
+ if (content.includes('/_npx/')) {
63
+ throw new Error('Plist references stale npx cache path. Run \'poller update\' to fix.');
64
+ }
65
+ return 'Installed with KeepAlive';
66
+ });
67
+ }
68
+ function checkServiceRunning() {
69
+ return check('Service status', () => {
70
+ if (process.platform !== 'darwin') {
71
+ return 'Skipped (not macOS)';
72
+ }
73
+ try {
74
+ const output = execSync(`launchctl list | grep ${SERVICE_LABEL}`, { encoding: 'utf-8' });
75
+ if (output.trim()) {
76
+ const parts = output.trim().split('\t');
77
+ const pid = parts[0];
78
+ const lastExitStatus = parts[1];
79
+ if (pid === '-') {
80
+ throw new Error(`Service loaded but not running (last exit: ${lastExitStatus})`);
81
+ }
82
+ return `Running (PID: ${pid})`;
83
+ }
84
+ throw new Error('Service not loaded');
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error && error.message.includes('Service')) {
88
+ throw error;
89
+ }
90
+ throw new Error('Service not loaded in launchd');
91
+ }
92
+ });
93
+ }
94
+ function checkLogsDir() {
95
+ return check('Logs directory', () => {
96
+ if (!fs.existsSync(LOGS_DIR)) {
97
+ throw new Error(`${LOGS_DIR} does not exist`);
98
+ }
99
+ const stdoutLog = path.join(LOGS_DIR, 'poller.stdout.log');
100
+ const stderrLog = path.join(LOGS_DIR, 'poller.stderr.log');
101
+ const hasStdout = fs.existsSync(stdoutLog);
102
+ const hasStderr = fs.existsSync(stderrLog);
103
+ if (!hasStdout && !hasStderr) {
104
+ return 'Directory exists but no log files yet';
105
+ }
106
+ return 'OK';
107
+ });
108
+ }
109
+ function checkClaudeCli() {
110
+ return check('Claude CLI', () => {
111
+ try {
112
+ const output = execSync('which claude', { encoding: 'utf-8' }).trim();
113
+ if (!output) {
114
+ throw new Error('Claude CLI not found in PATH');
115
+ }
116
+ // Try to get version
117
+ try {
118
+ const version = execSync('claude --version 2>/dev/null', { encoding: 'utf-8' }).trim();
119
+ return `${output} (${version})`;
120
+ }
121
+ catch {
122
+ return output;
123
+ }
124
+ }
125
+ catch {
126
+ throw new Error('Claude CLI not found in PATH. Install Claude Code first.');
127
+ }
128
+ });
129
+ }
130
+ function checkBrainsDir() {
131
+ return check('Brains directory', () => {
132
+ const brainsPath = path.join(TEAMVIBE_DIR, 'brains');
133
+ if (!fs.existsSync(brainsPath)) {
134
+ return 'Not yet created (will be created on first message)';
135
+ }
136
+ const brains = fs.readdirSync(brainsPath).filter(f => {
137
+ const stat = fs.statSync(path.join(brainsPath, f));
138
+ return stat.isDirectory();
139
+ });
140
+ return `${brains.length} brain(s) cloned`;
141
+ });
142
+ }
143
+ function checkDiskSpace() {
144
+ 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
+ }
164
+ });
165
+ }
166
+ export async function doctor() {
167
+ console.log('TeamVibe Poller - Health Check\n');
168
+ const results = [
169
+ checkNodeVersion(),
170
+ checkTeamVibeDir(),
171
+ checkEnvFile(),
172
+ checkLaunchdPlist(),
173
+ checkServiceRunning(),
174
+ checkLogsDir(),
175
+ checkClaudeCli(),
176
+ checkBrainsDir(),
177
+ checkDiskSpace(),
178
+ ];
179
+ let hasFailure = false;
180
+ for (const result of results) {
181
+ const icon = result.ok ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
182
+ console.log(` ${icon} ${result.name}: ${result.message}`);
183
+ if (!result.ok)
184
+ hasFailure = true;
185
+ }
186
+ console.log('');
187
+ if (hasFailure) {
188
+ console.log('\x1b[31mSome checks failed. Fix the issues above before proceeding.\x1b[0m');
189
+ return false;
190
+ }
191
+ else {
192
+ console.log('\x1b[32mAll checks passed. Poller is healthy.\x1b[0m');
193
+ return true;
194
+ }
195
+ }
package/dist/config.d.ts CHANGED
@@ -15,6 +15,7 @@ declare const configSchema: z.ZodObject<{
15
15
  LOCK_RETRY_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
16
16
  IDLE_WATCHDOG_WARN_MS: z.ZodDefault<z.ZodNumber>;
17
17
  IDLE_WATCHDOG_KILL_MS: z.ZodDefault<z.ZodNumber>;
18
+ NETWORK_ERROR_KILL_MS: z.ZodDefault<z.ZodNumber>;
18
19
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
19
20
  BRAINS_PATH: z.ZodDefault<z.ZodString>;
20
21
  DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
@@ -29,6 +30,8 @@ declare const configSchema: z.ZodObject<{
29
30
  MAINTENANCE_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
30
31
  MAINTENANCE_CHECK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
31
32
  MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
33
+ AUTO_UPDATE_POLICY: z.ZodDefault<z.ZodEnum<["patch", "minor", "off"]>>;
34
+ AUTO_UPDATE_SCHEDULE: z.ZodDefault<z.ZodString>;
32
35
  }, "strip", z.ZodTypeAny, {
33
36
  AWS_REGION: string;
34
37
  TEAMVIBE_API_URL: string;
@@ -41,6 +44,7 @@ declare const configSchema: z.ZodObject<{
41
44
  LOCK_RETRY_INTERVAL_MS: number;
42
45
  IDLE_WATCHDOG_WARN_MS: number;
43
46
  IDLE_WATCHDOG_KILL_MS: number;
47
+ NETWORK_ERROR_KILL_MS: number;
44
48
  TEAMVIBE_DATA_DIR: string;
45
49
  BRAINS_PATH: string;
46
50
  DEFAULT_BRAIN_PATH: string;
@@ -55,6 +59,8 @@ declare const configSchema: z.ZodObject<{
55
59
  MAINTENANCE_INTERVAL_MS: number;
56
60
  MAINTENANCE_CHECK_INTERVAL_MS: number;
57
61
  MAINTENANCE_ENABLED: boolean;
62
+ AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
63
+ AUTO_UPDATE_SCHEDULE: string;
58
64
  SQS_QUEUE_URL?: string | undefined;
59
65
  SESSIONS_TABLE?: string | undefined;
60
66
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
@@ -73,6 +79,7 @@ declare const configSchema: z.ZodObject<{
73
79
  LOCK_RETRY_INTERVAL_MS?: number | undefined;
74
80
  IDLE_WATCHDOG_WARN_MS?: number | undefined;
75
81
  IDLE_WATCHDOG_KILL_MS?: number | undefined;
82
+ NETWORK_ERROR_KILL_MS?: number | undefined;
76
83
  TEAMVIBE_DATA_DIR?: string | undefined;
77
84
  BRAINS_PATH?: string | undefined;
78
85
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -87,6 +94,8 @@ declare const configSchema: z.ZodObject<{
87
94
  MAINTENANCE_INTERVAL_MS?: number | undefined;
88
95
  MAINTENANCE_CHECK_INTERVAL_MS?: number | undefined;
89
96
  MAINTENANCE_ENABLED?: string | undefined;
97
+ AUTO_UPDATE_POLICY?: "patch" | "minor" | "off" | undefined;
98
+ AUTO_UPDATE_SCHEDULE?: string | undefined;
90
99
  }>;
91
100
  export type Config = z.infer<typeof configSchema>;
92
101
  export declare function loadConfig(): Config;
@@ -102,6 +111,7 @@ export declare const config: {
102
111
  LOCK_RETRY_INTERVAL_MS: number;
103
112
  IDLE_WATCHDOG_WARN_MS: number;
104
113
  IDLE_WATCHDOG_KILL_MS: number;
114
+ NETWORK_ERROR_KILL_MS: number;
105
115
  TEAMVIBE_DATA_DIR: string;
106
116
  BRAINS_PATH: string;
107
117
  DEFAULT_BRAIN_PATH: string;
@@ -116,6 +126,8 @@ export declare const config: {
116
126
  MAINTENANCE_INTERVAL_MS: number;
117
127
  MAINTENANCE_CHECK_INTERVAL_MS: number;
118
128
  MAINTENANCE_ENABLED: boolean;
129
+ AUTO_UPDATE_POLICY: "patch" | "minor" | "off";
130
+ AUTO_UPDATE_SCHEDULE: string;
119
131
  SQS_QUEUE_URL?: string | undefined;
120
132
  SESSIONS_TABLE?: string | undefined;
121
133
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
package/dist/config.js CHANGED
@@ -20,10 +20,11 @@ const configSchema = z.object({
20
20
  VISIBILITY_TIMEOUT_SECONDS: z.coerce.number().default(60),
21
21
  HEARTBEAT_INTERVAL_MS: z.coerce.number().default(40000), // 40 seconds
22
22
  CLAUDE_TIMEOUT_MS: z.coerce.number().default(3600000), // 60 minutes
23
- STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(3900000), // 65 minutes
23
+ STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(900000), // 15 minutes (above idle watchdog kill time of 10 min)
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
28
  // Paths
28
29
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
29
30
  BRAINS_PATH: z.string().default(''),
@@ -45,6 +46,9 @@ const configSchema = z.object({
45
46
  MAINTENANCE_INTERVAL_MS: z.coerce.number().default(21600000), // 6 hours between maintenance per brain
46
47
  MAINTENANCE_CHECK_INTERVAL_MS: z.coerce.number().default(300000), // 5 min check loop
47
48
  MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
49
+ // Auto-update settings
50
+ AUTO_UPDATE_POLICY: z.enum(['patch', 'minor', 'off']).default('patch'),
51
+ AUTO_UPDATE_SCHEDULE: z.string().default('03:00'), // HH:MM in UTC — daily check time
48
52
  });
49
53
  export function loadConfig() {
50
54
  const result = configSchema.safeParse(process.env);
@@ -0,0 +1,4 @@
1
+ import type { ControlMessage } from './types.js';
2
+ export type { ControlMessage };
3
+ export declare function isControlMessage(message: unknown): message is ControlMessage;
4
+ export declare function handleControlMessage(control: ControlMessage): Promise<void>;
@@ -0,0 +1,143 @@
1
+ import { execSync } from 'child_process';
2
+ import { createRequire } from 'module';
3
+ import { logger } from './logger.js';
4
+ import { emitEvent, flush as flushEventQueue } from './event-queue.js';
5
+ import { doctor } from './cli/doctor.js';
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require('../package.json');
8
+ const UPDATE_TIMEOUT_MS = 120_000; // 2 minutes
9
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
10
+ export function isControlMessage(message) {
11
+ if (typeof message !== 'object' || message === null)
12
+ return false;
13
+ const msg = message;
14
+ return msg['source'] === 'control' && (msg['action'] === 'restart' || msg['action'] === 'update');
15
+ }
16
+ export async function handleControlMessage(control) {
17
+ logger.info(`[Control] Received action: ${control.action}`);
18
+ if (control.action === 'restart') {
19
+ await handleRestart();
20
+ }
21
+ else if (control.action === 'update') {
22
+ await handleUpdate(control.targetVersion);
23
+ }
24
+ }
25
+ async function handleRestart() {
26
+ logger.info('[Control] Initiating graceful restart...');
27
+ emitEvent('poller.restart', 'system', { version: pkg.version });
28
+ await flushEventQueue();
29
+ process.exit(0);
30
+ }
31
+ async function handleUpdate(targetVersion) {
32
+ const currentVersion = pkg.version;
33
+ logger.info(`[Control] Starting safe update. Current version: ${currentVersion}`);
34
+ // Defense-in-depth: sanitize targetVersion even though resolver validates too
35
+ if (targetVersion && !SEMVER_RE.test(targetVersion)) {
36
+ logger.error(`[Control] Invalid targetVersion "${targetVersion}" — must be semver (e.g. 1.2.3)`);
37
+ emitEvent('poller.update.failed', 'system', { reason: 'invalid-version', currentVersion, targetVersion });
38
+ await flushEventQueue();
39
+ return;
40
+ }
41
+ // Step 1: Pre-check — verify current state is healthy
42
+ logger.info('[Control] Running pre-update health check...');
43
+ const preCheckOk = await doctor();
44
+ if (!preCheckOk) {
45
+ logger.error('[Control] Pre-update doctor check failed. Aborting update.');
46
+ emitEvent('poller.update.failed', 'system', {
47
+ reason: 'pre-check-failed',
48
+ currentVersion,
49
+ });
50
+ await flushEventQueue();
51
+ return;
52
+ }
53
+ // Step 2: Determine target version
54
+ // Without explicit targetVersion, find latest and only allow patch updates
55
+ let resolvedVersion = targetVersion;
56
+ if (!resolvedVersion) {
57
+ try {
58
+ resolvedVersion = execSync('npm view @teamvibe/poller version', {
59
+ encoding: 'utf-8',
60
+ timeout: 15_000,
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ }).trim();
63
+ }
64
+ catch {
65
+ logger.error('[Control] Failed to check npm registry for latest version');
66
+ emitEvent('poller.update.failed', 'system', { reason: 'registry-check-failed', currentVersion });
67
+ await flushEventQueue();
68
+ return;
69
+ }
70
+ if (!SEMVER_RE.test(resolvedVersion)) {
71
+ logger.error(`[Control] npm returned invalid version: "${resolvedVersion}"`);
72
+ return;
73
+ }
74
+ if (resolvedVersion === currentVersion) {
75
+ logger.info(`[Control] Already on latest version (${currentVersion})`);
76
+ return;
77
+ }
78
+ }
79
+ const packageSpec = `@teamvibe/poller@${resolvedVersion}`;
80
+ logger.info(`[Control] Installing ${packageSpec}...`);
81
+ try {
82
+ execSync(`npm install -g ${packageSpec}`, {
83
+ timeout: UPDATE_TIMEOUT_MS,
84
+ stdio: 'pipe',
85
+ encoding: 'utf-8',
86
+ });
87
+ logger.info('[Control] npm install completed');
88
+ }
89
+ catch (error) {
90
+ const errorMsg = error instanceof Error ? error.message : String(error);
91
+ logger.error(`[Control] Update failed: ${errorMsg}`);
92
+ // Update failed — no rollback needed (original version still in place)
93
+ emitEvent('poller.update.failed', 'system', {
94
+ reason: 'install-failed',
95
+ currentVersion,
96
+ targetVersion: resolvedVersion,
97
+ error: errorMsg,
98
+ });
99
+ await flushEventQueue();
100
+ return;
101
+ }
102
+ // Step 3: Post-update doctor check
103
+ logger.info('[Control] Running post-update health check...');
104
+ const postCheckOk = await doctor();
105
+ if (!postCheckOk) {
106
+ logger.warn(`[Control] Post-update doctor failed. Rolling back to ${currentVersion}...`);
107
+ // Step 4: Rollback
108
+ try {
109
+ execSync(`npm install -g @teamvibe/poller@${currentVersion}`, {
110
+ timeout: UPDATE_TIMEOUT_MS,
111
+ stdio: 'pipe',
112
+ encoding: 'utf-8',
113
+ });
114
+ logger.info(`[Control] Rolled back to ${currentVersion}`);
115
+ emitEvent('poller.update.rolledback', 'system', {
116
+ currentVersion,
117
+ targetVersion: resolvedVersion,
118
+ reason: 'post-check-failed',
119
+ });
120
+ }
121
+ catch (rollbackError) {
122
+ const errMsg = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
123
+ logger.error(`[Control] CRITICAL: Rollback failed: ${errMsg}`);
124
+ emitEvent('poller.update.rollback-failed', 'system', {
125
+ currentVersion,
126
+ targetVersion: resolvedVersion,
127
+ error: errMsg,
128
+ });
129
+ }
130
+ await flushEventQueue();
131
+ // Restart anyway — launchd will bring us back (exit 1 to signal failure)
132
+ process.exit(1);
133
+ return;
134
+ }
135
+ // Step 5: Success — restart with new version
136
+ logger.info('[Control] Update successful. Restarting with new version...');
137
+ emitEvent('poller.update.success', 'system', {
138
+ previousVersion: currentVersion,
139
+ targetVersion: resolvedVersion,
140
+ });
141
+ await flushEventQueue();
142
+ process.exit(0);
143
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { isControlMessage } from './control-handler.js';
3
+ import { classifyUpdate } from './auto-update.js';
4
+ describe('isControlMessage', () => {
5
+ test('valid restart message', () => {
6
+ expect(isControlMessage({ source: 'control', action: 'restart' })).toBe(true);
7
+ });
8
+ test('valid update message', () => {
9
+ expect(isControlMessage({ source: 'control', action: 'update' })).toBe(true);
10
+ });
11
+ test('valid update with targetVersion', () => {
12
+ expect(isControlMessage({ source: 'control', action: 'update', targetVersion: '1.2.3' })).toBe(true);
13
+ });
14
+ test('rejects non-control source', () => {
15
+ expect(isControlMessage({ source: 'slack', action: 'restart' })).toBe(false);
16
+ });
17
+ test('rejects unknown action', () => {
18
+ expect(isControlMessage({ source: 'control', action: 'delete' })).toBe(false);
19
+ });
20
+ test('rejects null', () => {
21
+ expect(isControlMessage(null)).toBe(false);
22
+ });
23
+ test('rejects undefined', () => {
24
+ expect(isControlMessage(undefined)).toBe(false);
25
+ });
26
+ test('rejects string', () => {
27
+ expect(isControlMessage('control')).toBe(false);
28
+ });
29
+ test('rejects empty object', () => {
30
+ expect(isControlMessage({})).toBe(false);
31
+ });
32
+ test('rejects regular queue message', () => {
33
+ expect(isControlMessage({
34
+ id: '123',
35
+ source: 'cron',
36
+ thread_id: 'thread1',
37
+ sender: { id: 'user1', name: 'Test' },
38
+ text: 'hello',
39
+ })).toBe(false);
40
+ });
41
+ });
42
+ describe('classifyUpdate', () => {
43
+ test('detects patch update', () => {
44
+ expect(classifyUpdate('1.2.3', '1.2.4')).toBe('patch');
45
+ });
46
+ test('detects minor update', () => {
47
+ expect(classifyUpdate('1.2.3', '1.3.0')).toBe('minor');
48
+ });
49
+ test('detects major update', () => {
50
+ expect(classifyUpdate('1.2.3', '2.0.0')).toBe('major');
51
+ });
52
+ test('returns null for same version', () => {
53
+ expect(classifyUpdate('1.2.3', '1.2.3')).toBeNull();
54
+ });
55
+ test('returns null for downgrade (patch)', () => {
56
+ expect(classifyUpdate('1.2.4', '1.2.3')).toBeNull();
57
+ });
58
+ test('returns null for downgrade (minor)', () => {
59
+ expect(classifyUpdate('1.3.0', '1.2.5')).toBeNull();
60
+ });
61
+ test('returns null for downgrade (major)', () => {
62
+ expect(classifyUpdate('2.0.0', '1.9.9')).toBeNull();
63
+ });
64
+ test('handles zero versions', () => {
65
+ expect(classifyUpdate('0.0.1', '0.0.2')).toBe('patch');
66
+ expect(classifyUpdate('0.1.0', '0.2.0')).toBe('minor');
67
+ });
68
+ test('minor update with different patch', () => {
69
+ expect(classifyUpdate('1.2.3', '1.3.5')).toBe('minor');
70
+ });
71
+ test('major update with different minor/patch', () => {
72
+ expect(classifyUpdate('1.2.3', '2.1.0')).toBe('major');
73
+ });
74
+ });
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  const command = process.argv[2];
3
- if (command && ['install', 'uninstall', 'update', 'logs', 'status', 'start', 'stop', 'restart', 'reset', '--help', '-h', '--version', '-v'].includes(command)) {
3
+ if (command && ['install', 'uninstall', 'update', 'doctor', 'logs', 'status', 'start', 'stop', 'restart', 'reset', '--help', '-h', '--version', '-v'].includes(command)) {
4
4
  const { handleCommand } = await import('./cli/commands.js');
5
5
  await handleCommand(command);
6
6
  }
package/dist/poller.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { config } from './config.js';
2
2
  import { logger } from './logger.js';
3
3
  import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
- import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses } from './claude-spawner.js';
4
+ import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, killZombieForThread } from './claude-spawner.js';
5
5
  import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
6
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
7
7
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
@@ -10,8 +10,45 @@ import { initAuth, stopRefresh } from './auth-provider.js';
10
10
  import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
11
11
  import { startEventQueue, stopEventQueue, flush as flushEventQueue, emitEvent } from './event-queue.js';
12
12
  import { writeMessage, pickNextMessage, markProcessed, markFailed, hasUnprocessed } from './inbox-manager.js';
13
+ import { Cron } from 'croner';
14
+ import { isControlMessage, handleControlMessage } from './control-handler.js';
15
+ import { checkForUpdate } from './auto-update.js';
13
16
  // Track active message processing
14
17
  const processingMessages = new Set();
18
+ // Poller auto-update cron (separate from brain heartbeats)
19
+ let autoUpdateJob = null;
20
+ function startAutoUpdateCron() {
21
+ const schedule = config.AUTO_UPDATE_SCHEDULE;
22
+ const match = schedule.match(/^(\d{2}):(\d{2})$/);
23
+ if (!match || config.AUTO_UPDATE_POLICY === 'off') {
24
+ if (config.AUTO_UPDATE_POLICY !== 'off') {
25
+ logger.warn(`[AutoUpdate] Invalid AUTO_UPDATE_SCHEDULE: "${schedule}", expected "HH:MM"`);
26
+ }
27
+ return;
28
+ }
29
+ // Convert "HH:MM" to cron expression: "MM HH * * *"
30
+ const cronExpr = `${match[2]} ${match[1]} * * *`;
31
+ logger.info(`[AutoUpdate] Scheduled daily at ${schedule} UTC (cron: ${cronExpr}), policy: ${config.AUTO_UPDATE_POLICY}`);
32
+ autoUpdateJob = new Cron(cronExpr, { timezone: 'UTC' }, async () => {
33
+ try {
34
+ const shouldRestart = await checkForUpdate();
35
+ if (shouldRestart) {
36
+ logger.info('[AutoUpdate] Update applied, restarting...');
37
+ await flushEventQueue();
38
+ process.exit(0);
39
+ }
40
+ }
41
+ catch (error) {
42
+ logger.error(`[AutoUpdate] Check error: ${error instanceof Error ? error.message : error}`);
43
+ }
44
+ });
45
+ }
46
+ function stopAutoUpdateCron() {
47
+ if (autoUpdateJob) {
48
+ autoUpdateJob.stop();
49
+ autoUpdateJob = null;
50
+ }
51
+ }
15
52
  // Track which threads have an active processing loop
16
53
  const activeLoops = new Set();
17
54
  async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
@@ -25,8 +62,17 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
25
62
  try {
26
63
  let lockResult = await acquireSessionLock(threadId, kbPath);
27
64
  if (!lockResult.success) {
28
- sessionLog.info(`Session ${threadId} is locked, inbox message will be picked up by active session`);
29
- return;
65
+ // If lock acquisition failed, check for zombie processes holding the lock
66
+ const killed = killZombieForThread(threadId);
67
+ if (killed) {
68
+ sessionLog.info(`Killed zombie for thread ${threadId}, retrying lock acquisition`);
69
+ await new Promise((resolve) => setTimeout(resolve, 2000));
70
+ lockResult = await acquireSessionLock(threadId, kbPath);
71
+ }
72
+ if (!lockResult.success) {
73
+ sessionLog.info(`Session ${threadId} is locked, inbox message will be picked up by active session`);
74
+ return;
75
+ }
30
76
  }
31
77
  const { session, lockToken } = lockResult;
32
78
  let currentSessionId = session.session_id || undefined;
@@ -281,7 +327,20 @@ async function pollLoop() {
281
327
  if (duplicates.length > 0) {
282
328
  logger.info(`Deduplicated: ${messages.length} -> ${unique.length} message(s)`);
283
329
  }
284
- const processPromises = unique.map((msg) => processMessage(msg).catch((error) => {
330
+ // Handle control messages immediately (restart/update) — no Claude session needed
331
+ const regularMessages = [];
332
+ for (const msg of unique) {
333
+ const raw = msg.queueMessage;
334
+ if (isControlMessage(raw)) {
335
+ logger.info(`[Control] Received control message: ${raw.action}`);
336
+ await deleteMessage(msg.receiptHandle);
337
+ await handleControlMessage(raw);
338
+ // If we get here, the action didn't trigger an exit (e.g. update failed pre-check)
339
+ continue;
340
+ }
341
+ regularMessages.push(msg);
342
+ }
343
+ const processPromises = regularMessages.map((msg) => processMessage(msg).catch((error) => {
285
344
  logger.error(`Failed to process message ${msg.messageId}:`, error);
286
345
  }));
287
346
  // Don't await - let them run in parallel
@@ -305,6 +364,7 @@ async function shutdown(signal) {
305
364
  logger.info(`${signal} received, shutting down gracefully...`);
306
365
  stopRefresh();
307
366
  stopHeartbeatLoop();
367
+ stopAutoUpdateCron();
308
368
  stopEventQueue();
309
369
  // Kill all active Claude child processes
310
370
  await killAllActiveProcesses();
@@ -342,5 +402,6 @@ export async function startPoller() {
342
402
  await ensureDirectories();
343
403
  await ensureBaseBrain();
344
404
  startHeartbeatLoop(processMessage);
405
+ startAutoUpdateCron();
345
406
  await pollLoop();
346
407
  }
package/dist/types.d.ts CHANGED
@@ -1,3 +1,10 @@
1
+ export interface ControlMessage {
2
+ id: string;
3
+ source: 'control';
4
+ action: 'restart' | 'update';
5
+ targetVersion?: string;
6
+ }
7
+ export type QueueMessage = TeamVibeQueueMessage | ControlMessage;
1
8
  export interface TeamVibeQueueMessage {
2
9
  id: string;
3
10
  source: 'slack' | 'email' | 'cron' | 'heartbeat' | 'webhook';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "@aws-sdk/client-sqs": "^3.470.0",
25
25
  "@aws-sdk/lib-dynamodb": "^3.470.0",
26
26
  "@slack/web-api": "^7.0.0",
27
+ "croner": "^10.0.1",
27
28
  "dotenv": "^17.2.3",
28
29
  "zod": "^3.22.0"
29
30
  },