@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.
- package/dist/auto-update.d.ts +15 -0
- package/dist/auto-update.js +151 -0
- package/dist/claude-spawner.d.ts +5 -0
- package/dist/claude-spawner.js +68 -2
- package/dist/cli/commands.js +7 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +195 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +5 -1
- package/dist/control-handler.d.ts +4 -0
- package/dist/control-handler.js +143 -0
- package/dist/control-handler.test.d.ts +1 -0
- package/dist/control-handler.test.js +74 -0
- package/dist/index.js +1 -1
- package/dist/poller.js +65 -4
- package/dist/types.d.ts +7 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -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>;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/cli/commands.js
CHANGED
|
@@ -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(
|
|
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,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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
},
|