@teamvibe/poller 0.1.45 → 0.1.47

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.
@@ -82,6 +82,10 @@ async function countNewThreadMessages(botToken, channel, threadTs, sinceTs) {
82
82
  }
83
83
  }
84
84
  function buildPrompt(msg, resumeHint) {
85
+ // Heartbeat messages get a minimal prompt — CLAUDE.md/MAINTENANCE.md handle the rest
86
+ if (msg.source === 'heartbeat') {
87
+ return 'heartbeat';
88
+ }
85
89
  let prompt = '## Incoming Slack Message\n\n';
86
90
  prompt += `**From:** ${msg.sender.name}`;
87
91
  if (msg.sender.id && msg.sender.id !== msg.sender.name) {
@@ -173,7 +177,8 @@ function formatStreamEvent(event) {
173
177
  async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = true, lastMessageTs, onMessageSent, typingIndicator) {
174
178
  const slackContext = msg.response_context.slack;
175
179
  const isCronMessage = msg.source === 'cron';
176
- if (!slackContext && !isCronMessage) {
180
+ const isHeartbeat = msg.source === 'heartbeat';
181
+ if (!slackContext && !isCronMessage && !isHeartbeat) {
177
182
  throw new Error('No Slack context in message');
178
183
  }
179
184
  // Build resume hint
package/dist/config.d.ts CHANGED
@@ -12,6 +12,7 @@ declare const configSchema: z.ZodObject<{
12
12
  HEARTBEAT_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
13
13
  CLAUDE_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
14
14
  STALE_LOCK_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
15
+ LOCK_RETRY_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
15
16
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
16
17
  BRAINS_PATH: z.ZodDefault<z.ZodString>;
17
18
  DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
@@ -22,6 +23,9 @@ declare const configSchema: z.ZodObject<{
22
23
  BASE_BRAIN_PATH: z.ZodDefault<z.ZodString>;
23
24
  BRAIN_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
24
25
  BRAIN_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
26
+ MAINTENANCE_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
27
+ MAINTENANCE_CHECK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
28
+ MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
25
29
  }, "strip", z.ZodTypeAny, {
26
30
  AWS_REGION: string;
27
31
  TEAMVIBE_API_URL: string;
@@ -31,6 +35,7 @@ declare const configSchema: z.ZodObject<{
31
35
  HEARTBEAT_INTERVAL_MS: number;
32
36
  CLAUDE_TIMEOUT_MS: number;
33
37
  STALE_LOCK_TIMEOUT_MS: number;
38
+ LOCK_RETRY_INTERVAL_MS: number;
34
39
  TEAMVIBE_DATA_DIR: string;
35
40
  BRAINS_PATH: string;
36
41
  DEFAULT_BRAIN_PATH: string;
@@ -41,6 +46,9 @@ declare const configSchema: z.ZodObject<{
41
46
  BASE_BRAIN_PATH: string;
42
47
  BRAIN_AUTO_UPDATE: boolean;
43
48
  BRAIN_UPDATE_COOLDOWN_MS: number;
49
+ MAINTENANCE_INTERVAL_MS: number;
50
+ MAINTENANCE_CHECK_INTERVAL_MS: number;
51
+ MAINTENANCE_ENABLED: boolean;
44
52
  SQS_QUEUE_URL?: string | undefined;
45
53
  SESSIONS_TABLE?: string | undefined;
46
54
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
@@ -56,6 +64,7 @@ declare const configSchema: z.ZodObject<{
56
64
  HEARTBEAT_INTERVAL_MS?: number | undefined;
57
65
  CLAUDE_TIMEOUT_MS?: number | undefined;
58
66
  STALE_LOCK_TIMEOUT_MS?: number | undefined;
67
+ LOCK_RETRY_INTERVAL_MS?: number | undefined;
59
68
  TEAMVIBE_DATA_DIR?: string | undefined;
60
69
  BRAINS_PATH?: string | undefined;
61
70
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -66,6 +75,9 @@ declare const configSchema: z.ZodObject<{
66
75
  BASE_BRAIN_PATH?: string | undefined;
67
76
  BRAIN_AUTO_UPDATE?: string | undefined;
68
77
  BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
78
+ MAINTENANCE_INTERVAL_MS?: number | undefined;
79
+ MAINTENANCE_CHECK_INTERVAL_MS?: number | undefined;
80
+ MAINTENANCE_ENABLED?: string | undefined;
69
81
  }>;
70
82
  export type Config = z.infer<typeof configSchema>;
71
83
  export declare function loadConfig(): Config;
@@ -78,6 +90,7 @@ export declare const config: {
78
90
  HEARTBEAT_INTERVAL_MS: number;
79
91
  CLAUDE_TIMEOUT_MS: number;
80
92
  STALE_LOCK_TIMEOUT_MS: number;
93
+ LOCK_RETRY_INTERVAL_MS: number;
81
94
  TEAMVIBE_DATA_DIR: string;
82
95
  BRAINS_PATH: string;
83
96
  DEFAULT_BRAIN_PATH: string;
@@ -88,6 +101,9 @@ export declare const config: {
88
101
  BASE_BRAIN_PATH: string;
89
102
  BRAIN_AUTO_UPDATE: boolean;
90
103
  BRAIN_UPDATE_COOLDOWN_MS: number;
104
+ MAINTENANCE_INTERVAL_MS: number;
105
+ MAINTENANCE_CHECK_INTERVAL_MS: number;
106
+ MAINTENANCE_ENABLED: boolean;
91
107
  SQS_QUEUE_URL?: string | undefined;
92
108
  SESSIONS_TABLE?: string | undefined;
93
109
  TEAMVIBE_POLLER_TOKEN?: string | undefined;
package/dist/config.js CHANGED
@@ -21,6 +21,7 @@ const configSchema = z.object({
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
23
  STALE_LOCK_TIMEOUT_MS: z.coerce.number().default(3900000), // 65 minutes
24
+ LOCK_RETRY_INTERVAL_MS: z.coerce.number().default(30000), // 30 seconds
24
25
  // Paths
25
26
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
26
27
  BRAINS_PATH: z.string().default(''),
@@ -37,6 +38,10 @@ const configSchema = z.object({
37
38
  .default('true')
38
39
  .transform((v) => v.toLowerCase() === 'true'),
39
40
  BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
41
+ // Maintenance heartbeat
42
+ MAINTENANCE_INTERVAL_MS: z.coerce.number().default(21600000), // 6 hours between maintenance per brain
43
+ MAINTENANCE_CHECK_INTERVAL_MS: z.coerce.number().default(300000), // 5 min check loop
44
+ MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
40
45
  });
41
46
  export function loadConfig() {
42
47
  const result = configSchema.safeParse(process.env);
@@ -0,0 +1,19 @@
1
+ import type { TeamVibeQueueMessage } from './types.js';
2
+ /**
3
+ * Register a brain from a real message so we know about it for heartbeats.
4
+ * Called from processMessage() after brain path is resolved.
5
+ */
6
+ export declare function registerBrain(msg: TeamVibeQueueMessage): void;
7
+ /**
8
+ * Start the periodic heartbeat check loop.
9
+ * Checks every MAINTENANCE_CHECK_INTERVAL_MS whether any brain needs maintenance.
10
+ */
11
+ export declare function startHeartbeatLoop(processMessageFn: (msg: {
12
+ queueMessage: TeamVibeQueueMessage;
13
+ receiptHandle: string;
14
+ messageId: string;
15
+ }) => Promise<void>): void;
16
+ /**
17
+ * Stop the heartbeat loop (for shutdown).
18
+ */
19
+ export declare function stopHeartbeatLoop(): void;
@@ -0,0 +1,101 @@
1
+ import { config } from './config.js';
2
+ import { logger } from './logger.js';
3
+ import { isAtCapacity } from './claude-spawner.js';
4
+ const trackedBrains = new Map();
5
+ let heartbeatInterval = null;
6
+ /**
7
+ * Register a brain from a real message so we know about it for heartbeats.
8
+ * Called from processMessage() after brain path is resolved.
9
+ */
10
+ export function registerBrain(msg) {
11
+ const brain = msg.teamvibe.brain;
12
+ if (!brain?.brainId)
13
+ return;
14
+ // Don't register from heartbeat messages (avoid self-referential loop)
15
+ if (msg.source === 'heartbeat')
16
+ return;
17
+ const existing = trackedBrains.get(brain.brainId);
18
+ // Update brain info but preserve lastHeartbeat if it exists
19
+ trackedBrains.set(brain.brainId, {
20
+ brainId: brain.brainId,
21
+ gitRepoUrl: brain.gitRepoUrl,
22
+ branch: brain.branch,
23
+ workspaceId: msg.teamvibe.workspaceId,
24
+ botId: msg.teamvibe.botId,
25
+ botToken: msg.teamvibe.botToken,
26
+ channelId: msg.teamvibe.channelId,
27
+ lastHeartbeat: existing?.lastHeartbeat ?? 0,
28
+ });
29
+ logger.debug(`[Heartbeat] Registered brain: ${brain.brainId}`);
30
+ }
31
+ /**
32
+ * Start the periodic heartbeat check loop.
33
+ * Checks every MAINTENANCE_CHECK_INTERVAL_MS whether any brain needs maintenance.
34
+ */
35
+ export function startHeartbeatLoop(processMessageFn) {
36
+ if (!config.MAINTENANCE_ENABLED) {
37
+ logger.info('[Heartbeat] Maintenance disabled via config');
38
+ return;
39
+ }
40
+ logger.info(`[Heartbeat] Starting heartbeat loop (check every ${config.MAINTENANCE_CHECK_INTERVAL_MS}ms, interval ${config.MAINTENANCE_INTERVAL_MS}ms)`);
41
+ heartbeatInterval = setInterval(async () => {
42
+ const now = Date.now();
43
+ for (const [brainId, info] of trackedBrains.entries()) {
44
+ const elapsed = now - info.lastHeartbeat;
45
+ if (elapsed < config.MAINTENANCE_INTERVAL_MS) {
46
+ continue;
47
+ }
48
+ if (isAtCapacity()) {
49
+ logger.debug(`[Heartbeat] At capacity, skipping heartbeat for ${brainId}`);
50
+ continue;
51
+ }
52
+ logger.info(`[Heartbeat] Triggering maintenance for brain ${brainId} (last: ${info.lastHeartbeat === 0 ? 'never' : `${Math.round(elapsed / 60000)}m ago`})`);
53
+ // Update lastHeartbeat immediately to prevent double-triggering
54
+ info.lastHeartbeat = now;
55
+ const syntheticMessage = {
56
+ id: `heartbeat_${brainId}_${now}`,
57
+ source: 'heartbeat',
58
+ thread_id: `heartbeat_${brainId}_${now}`,
59
+ sender: {
60
+ id: 'system',
61
+ name: 'System',
62
+ },
63
+ text: 'heartbeat',
64
+ attachments: [],
65
+ response_context: {},
66
+ type: 'message',
67
+ teamvibe: {
68
+ workspaceId: info.workspaceId,
69
+ botId: info.botId,
70
+ botToken: info.botToken,
71
+ channelId: info.channelId,
72
+ brain: {
73
+ brainId: info.brainId,
74
+ gitRepoUrl: info.gitRepoUrl,
75
+ branch: info.branch,
76
+ },
77
+ },
78
+ };
79
+ try {
80
+ await processMessageFn({
81
+ queueMessage: syntheticMessage,
82
+ receiptHandle: '', // No SQS receipt for synthetic messages
83
+ messageId: syntheticMessage.id,
84
+ });
85
+ }
86
+ catch (error) {
87
+ logger.error(`[Heartbeat] Failed maintenance for ${brainId}: ${error instanceof Error ? error.message : error}`);
88
+ }
89
+ }
90
+ }, config.MAINTENANCE_CHECK_INTERVAL_MS);
91
+ }
92
+ /**
93
+ * Stop the heartbeat loop (for shutdown).
94
+ */
95
+ export function stopHeartbeatLoop() {
96
+ if (heartbeatInterval) {
97
+ clearInterval(heartbeatInterval);
98
+ heartbeatInterval = null;
99
+ logger.info('[Heartbeat] Stopped heartbeat loop');
100
+ }
101
+ }
package/dist/poller.js CHANGED
@@ -6,6 +6,7 @@ import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from '
6
6
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
7
7
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
8
8
  import { initAuth, stopRefresh } from './auth-provider.js';
9
+ import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
9
10
  // Track active message processing
10
11
  const processingMessages = new Set();
11
12
  // Per-thread completion signals
@@ -27,7 +28,9 @@ function logQueueState(context) {
27
28
  logger.info(`[Queue] ${context} | Processing: ${stats.processing}, Threads with waiting: ${stats.threadsWithWaiting}, Total waiting: ${stats.totalWaiting}`);
28
29
  }
29
30
  function waitForThreadCompletion(threadId) {
30
- return new Promise((resolve) => {
31
+ let resolveFn;
32
+ const promise = new Promise((resolve) => {
33
+ resolveFn = resolve;
31
34
  const signals = threadCompletionSignals.get(threadId) || [];
32
35
  signals.push(resolve);
33
36
  threadCompletionSignals.set(threadId, signals);
@@ -36,6 +39,25 @@ function waitForThreadCompletion(threadId) {
36
39
  logger.info(`[Queue] Thread ${threadId}: ${currentCount + 1} message(s) now waiting`);
37
40
  logQueueState('After enqueue');
38
41
  });
42
+ const cancel = () => {
43
+ // Remove this specific resolve from the signals array
44
+ const signals = threadCompletionSignals.get(threadId);
45
+ if (signals) {
46
+ const idx = signals.indexOf(resolveFn);
47
+ if (idx !== -1)
48
+ signals.splice(idx, 1);
49
+ if (signals.length === 0)
50
+ threadCompletionSignals.delete(threadId);
51
+ }
52
+ const count = waitingCountByThread.get(threadId) || 0;
53
+ if (count <= 1) {
54
+ waitingCountByThread.delete(threadId);
55
+ }
56
+ else {
57
+ waitingCountByThread.set(threadId, count - 1);
58
+ }
59
+ };
60
+ return { promise, cancel };
39
61
  }
40
62
  function signalThreadCompletion(threadId) {
41
63
  const signals = threadCompletionSignals.get(threadId);
@@ -65,7 +87,7 @@ async function processMessage(received) {
65
87
  const threadId = queueMessage.thread_id;
66
88
  const sessionLog = logger.createSession(logSessionId);
67
89
  // Fetch user info if we only have ID
68
- if (queueMessage.source !== 'cron' &&
90
+ if (queueMessage.source !== 'cron' && queueMessage.source !== 'heartbeat' &&
69
91
  queueMessage.sender.name === 'Unknown User' &&
70
92
  queueMessage.sender.id !== 'unknown') {
71
93
  const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
@@ -96,22 +118,40 @@ async function processMessage(received) {
96
118
  sessionLog.error(`Failed to send Slack error: ${slackError}`);
97
119
  }
98
120
  }
99
- await deleteMessage(receiptHandle);
121
+ if (receiptHandle) {
122
+ await deleteMessage(receiptHandle);
123
+ }
100
124
  return;
101
125
  }
126
+ // Register brain for heartbeat tracking
127
+ registerBrain(queueMessage);
102
128
  // Try to acquire session lock
103
129
  let lockResult = await acquireSessionLock(threadId, kbPath);
104
130
  if (!lockResult.success) {
105
131
  sessionLog.info(`Session ${threadId} is processing, waiting for completion...`);
106
132
  const waitHeartbeat = startHeartbeat(receiptHandle, sessionLog);
107
133
  try {
108
- await waitForThreadCompletion(threadId);
109
- sessionLog.info(`Thread ${threadId} completed, retrying lock acquisition`);
110
- lockResult = await acquireSessionLock(threadId, kbPath);
134
+ const maxWaitMs = config.STALE_LOCK_TIMEOUT_MS;
135
+ const retryIntervalMs = config.LOCK_RETRY_INTERVAL_MS;
136
+ const waitStart = Date.now();
137
+ while (Date.now() - waitStart < maxWaitMs) {
138
+ const waiter = waitForThreadCompletion(threadId);
139
+ // Wait for either: completion signal OR retry interval timeout
140
+ await Promise.race([
141
+ waiter.promise,
142
+ sleep(retryIntervalMs),
143
+ ]);
144
+ // Clean up the waiter (handles the timeout case where signal didn't fire)
145
+ waiter.cancel();
146
+ lockResult = await acquireSessionLock(threadId, kbPath);
147
+ if (lockResult.success)
148
+ break;
149
+ sessionLog.info(`Lock still held for ${threadId}, retrying in ${retryIntervalMs / 1000}s...`);
150
+ }
111
151
  if (!lockResult.success) {
112
- sessionLog.info('Lock still held, re-queuing for next completion');
113
- clearInterval(waitHeartbeat);
114
- return processMessage(received);
152
+ sessionLog.error(`Gave up waiting for lock on ${threadId} after ${maxWaitMs / 1000}s`);
153
+ await deleteMessage(receiptHandle);
154
+ return;
115
155
  }
116
156
  }
117
157
  finally {
@@ -160,7 +200,9 @@ async function processMessage(received) {
160
200
  await releaseSessionLock(threadId, lockToken, 'idle');
161
201
  }
162
202
  }
163
- await deleteMessage(receiptHandle);
203
+ if (receiptHandle) {
204
+ await deleteMessage(receiptHandle);
205
+ }
164
206
  sessionLog.info('Message processed and deleted');
165
207
  }
166
208
  catch (error) {
@@ -198,6 +240,25 @@ async function processMessage(received) {
198
240
  signalThreadCompletion(threadId);
199
241
  }
200
242
  }
243
+ function deduplicateMessages(messages) {
244
+ const byThread = new Map();
245
+ for (const msg of messages) {
246
+ const threadId = msg.queueMessage.thread_id;
247
+ const existing = byThread.get(threadId) || [];
248
+ existing.push(msg);
249
+ byThread.set(threadId, existing);
250
+ }
251
+ const unique = [];
252
+ const duplicates = [];
253
+ for (const [, msgs] of byThread) {
254
+ // Keep the last message (most recent), mark rest as duplicates
255
+ unique.push(msgs[msgs.length - 1]);
256
+ for (let i = 0; i < msgs.length - 1; i++) {
257
+ duplicates.push(msgs[i]);
258
+ }
259
+ }
260
+ return { unique, duplicates };
261
+ }
201
262
  async function pollLoop() {
202
263
  logger.info('Poll loop started');
203
264
  while (!shuttingDown) {
@@ -215,7 +276,16 @@ async function pollLoop() {
215
276
  }
216
277
  logger.info(`Received ${messages.length} message(s) from SQS`);
217
278
  logQueueState('After SQS poll');
218
- const processPromises = messages.map((msg) => processMessage(msg).catch((error) => {
279
+ // Deduplicate: if multiple messages for the same thread, keep only the latest
280
+ const { unique, duplicates } = deduplicateMessages(messages);
281
+ for (const dup of duplicates) {
282
+ logger.info(`Deleting duplicate message for thread ${dup.queueMessage.thread_id} (messageId: ${dup.messageId})`);
283
+ await deleteMessage(dup.receiptHandle);
284
+ }
285
+ if (duplicates.length > 0) {
286
+ logger.info(`Deduplicated: ${messages.length} -> ${unique.length} message(s)`);
287
+ }
288
+ const processPromises = unique.map((msg) => processMessage(msg).catch((error) => {
219
289
  logger.error(`Failed to process message ${msg.messageId}:`, error);
220
290
  }));
221
291
  // Don't await - let them run in parallel
@@ -238,6 +308,7 @@ async function shutdown(signal) {
238
308
  shuttingDown = true;
239
309
  logger.info(`${signal} received, shutting down gracefully...`);
240
310
  stopRefresh();
311
+ stopHeartbeatLoop();
241
312
  // Unblock all messages waiting on thread completion so they can exit
242
313
  for (const [threadId, signals] of threadCompletionSignals.entries()) {
243
314
  logger.info(`Unblocking ${signals.length} waiting message(s) on thread ${threadId}`);
@@ -277,5 +348,22 @@ export async function startPoller() {
277
348
  }
278
349
  await ensureDirectories();
279
350
  await ensureBaseBrain();
280
- await pollLoop();
351
+ startHeartbeatLoop(processMessage);
352
+ // Periodic queue health check — surfaces stuck messages in logs
353
+ const healthCheckInterval = setInterval(() => {
354
+ const stats = getQueueStats();
355
+ if (stats.totalWaiting > 0) {
356
+ logger.warn(`[Queue Health] ${stats.totalWaiting} message(s) waiting across ${stats.threadsWithWaiting} thread(s), ` +
357
+ `${stats.processing} actively processing`);
358
+ for (const [threadId, count] of waitingCountByThread.entries()) {
359
+ logger.warn(`[Queue Health] Thread ${threadId}: ${count} waiting`);
360
+ }
361
+ }
362
+ }, 60000);
363
+ try {
364
+ await pollLoop();
365
+ }
366
+ finally {
367
+ clearInterval(healthCheckInterval);
368
+ }
281
369
  }
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export interface TeamVibeQueueMessage {
2
2
  id: string;
3
- source: 'slack' | 'cron';
3
+ source: 'slack' | 'cron' | 'heartbeat';
4
4
  thread_id: string;
5
5
  sender: {
6
6
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {