@teamvibe/poller 0.1.66 → 0.1.68

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.
@@ -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';
@@ -152,8 +160,8 @@ export function buildPrompt(msg, resumeHint) {
152
160
  if (msg.type === 'view_closed' && msg.modal) {
153
161
  prompt += `\n\n**Modal Dismissed:** User closed "${msg.modal.callbackId}" without submitting.`;
154
162
  }
155
- if (msg.teamvibe.emailChannelPrompt) {
156
- prompt += '\n\n**Channel Instructions:**\n' + msg.teamvibe.emailChannelPrompt + '\n';
163
+ if (msg.teamvibe.emailHookPrompt) {
164
+ prompt += '\n\n**Channel Instructions:**\n' + msg.teamvibe.emailHookPrompt + '\n';
157
165
  }
158
166
  if (resumeHint) {
159
167
  prompt += `\n\n${resumeHint}`;
@@ -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
  }
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>;
@@ -41,6 +42,7 @@ declare const configSchema: z.ZodObject<{
41
42
  LOCK_RETRY_INTERVAL_MS: number;
42
43
  IDLE_WATCHDOG_WARN_MS: number;
43
44
  IDLE_WATCHDOG_KILL_MS: number;
45
+ NETWORK_ERROR_KILL_MS: number;
44
46
  TEAMVIBE_DATA_DIR: string;
45
47
  BRAINS_PATH: string;
46
48
  DEFAULT_BRAIN_PATH: string;
@@ -73,6 +75,7 @@ declare const configSchema: z.ZodObject<{
73
75
  LOCK_RETRY_INTERVAL_MS?: number | undefined;
74
76
  IDLE_WATCHDOG_WARN_MS?: number | undefined;
75
77
  IDLE_WATCHDOG_KILL_MS?: number | undefined;
78
+ NETWORK_ERROR_KILL_MS?: number | undefined;
76
79
  TEAMVIBE_DATA_DIR?: string | undefined;
77
80
  BRAINS_PATH?: string | undefined;
78
81
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -102,6 +105,7 @@ export declare const config: {
102
105
  LOCK_RETRY_INTERVAL_MS: number;
103
106
  IDLE_WATCHDOG_WARN_MS: number;
104
107
  IDLE_WATCHDOG_KILL_MS: number;
108
+ NETWORK_ERROR_KILL_MS: number;
105
109
  TEAMVIBE_DATA_DIR: string;
106
110
  BRAINS_PATH: string;
107
111
  DEFAULT_BRAIN_PATH: string;
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(''),
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';
@@ -25,8 +25,17 @@ async function runInboxLoop(threadId, kbPath, queueMessage, sessionLog) {
25
25
  try {
26
26
  let lockResult = await acquireSessionLock(threadId, kbPath);
27
27
  if (!lockResult.success) {
28
- sessionLog.info(`Session ${threadId} is locked, inbox message will be picked up by active session`);
29
- return;
28
+ // If lock acquisition failed, check for zombie processes holding the lock
29
+ const killed = killZombieForThread(threadId);
30
+ if (killed) {
31
+ sessionLog.info(`Killed zombie for thread ${threadId}, retrying lock acquisition`);
32
+ await new Promise((resolve) => setTimeout(resolve, 2000));
33
+ lockResult = await acquireSessionLock(threadId, kbPath);
34
+ }
35
+ if (!lockResult.success) {
36
+ sessionLog.info(`Session ${threadId} is locked, inbox message will be picked up by active session`);
37
+ return;
38
+ }
30
39
  }
31
40
  const { session, lockToken } = lockResult;
32
41
  let currentSessionId = session.session_id || undefined;
package/dist/types.d.ts CHANGED
@@ -50,7 +50,7 @@ export interface TeamVibeQueueMessage {
50
50
  gitRepoUrl: string;
51
51
  branch: string;
52
52
  };
53
- emailChannelPrompt?: string;
53
+ emailHookPrompt?: string;
54
54
  };
55
55
  }
56
56
  export interface SessionRecord {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {