@teamvibe/poller 0.1.46 → 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.
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>;
@@ -34,6 +35,7 @@ declare const configSchema: z.ZodObject<{
34
35
  HEARTBEAT_INTERVAL_MS: number;
35
36
  CLAUDE_TIMEOUT_MS: number;
36
37
  STALE_LOCK_TIMEOUT_MS: number;
38
+ LOCK_RETRY_INTERVAL_MS: number;
37
39
  TEAMVIBE_DATA_DIR: string;
38
40
  BRAINS_PATH: string;
39
41
  DEFAULT_BRAIN_PATH: string;
@@ -62,6 +64,7 @@ declare const configSchema: z.ZodObject<{
62
64
  HEARTBEAT_INTERVAL_MS?: number | undefined;
63
65
  CLAUDE_TIMEOUT_MS?: number | undefined;
64
66
  STALE_LOCK_TIMEOUT_MS?: number | undefined;
67
+ LOCK_RETRY_INTERVAL_MS?: number | undefined;
65
68
  TEAMVIBE_DATA_DIR?: string | undefined;
66
69
  BRAINS_PATH?: string | undefined;
67
70
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -87,6 +90,7 @@ export declare const config: {
87
90
  HEARTBEAT_INTERVAL_MS: number;
88
91
  CLAUDE_TIMEOUT_MS: number;
89
92
  STALE_LOCK_TIMEOUT_MS: number;
93
+ LOCK_RETRY_INTERVAL_MS: number;
90
94
  TEAMVIBE_DATA_DIR: string;
91
95
  BRAINS_PATH: string;
92
96
  DEFAULT_BRAIN_PATH: string;
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(''),
package/dist/poller.js CHANGED
@@ -28,7 +28,9 @@ function logQueueState(context) {
28
28
  logger.info(`[Queue] ${context} | Processing: ${stats.processing}, Threads with waiting: ${stats.threadsWithWaiting}, Total waiting: ${stats.totalWaiting}`);
29
29
  }
30
30
  function waitForThreadCompletion(threadId) {
31
- return new Promise((resolve) => {
31
+ let resolveFn;
32
+ const promise = new Promise((resolve) => {
33
+ resolveFn = resolve;
32
34
  const signals = threadCompletionSignals.get(threadId) || [];
33
35
  signals.push(resolve);
34
36
  threadCompletionSignals.set(threadId, signals);
@@ -37,6 +39,25 @@ function waitForThreadCompletion(threadId) {
37
39
  logger.info(`[Queue] Thread ${threadId}: ${currentCount + 1} message(s) now waiting`);
38
40
  logQueueState('After enqueue');
39
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 };
40
61
  }
41
62
  function signalThreadCompletion(threadId) {
42
63
  const signals = threadCompletionSignals.get(threadId);
@@ -110,13 +131,27 @@ async function processMessage(received) {
110
131
  sessionLog.info(`Session ${threadId} is processing, waiting for completion...`);
111
132
  const waitHeartbeat = startHeartbeat(receiptHandle, sessionLog);
112
133
  try {
113
- await waitForThreadCompletion(threadId);
114
- sessionLog.info(`Thread ${threadId} completed, retrying lock acquisition`);
115
- 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
+ }
116
151
  if (!lockResult.success) {
117
- sessionLog.info('Lock still held, re-queuing for next completion');
118
- clearInterval(waitHeartbeat);
119
- return processMessage(received);
152
+ sessionLog.error(`Gave up waiting for lock on ${threadId} after ${maxWaitMs / 1000}s`);
153
+ await deleteMessage(receiptHandle);
154
+ return;
120
155
  }
121
156
  }
122
157
  finally {
@@ -205,6 +240,25 @@ async function processMessage(received) {
205
240
  signalThreadCompletion(threadId);
206
241
  }
207
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
+ }
208
262
  async function pollLoop() {
209
263
  logger.info('Poll loop started');
210
264
  while (!shuttingDown) {
@@ -222,7 +276,16 @@ async function pollLoop() {
222
276
  }
223
277
  logger.info(`Received ${messages.length} message(s) from SQS`);
224
278
  logQueueState('After SQS poll');
225
- 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) => {
226
289
  logger.error(`Failed to process message ${msg.messageId}:`, error);
227
290
  }));
228
291
  // Don't await - let them run in parallel
@@ -286,5 +349,21 @@ export async function startPoller() {
286
349
  await ensureDirectories();
287
350
  await ensureBaseBrain();
288
351
  startHeartbeatLoop(processMessage);
289
- await pollLoop();
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
+ }
290
369
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {