@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.
- package/dist/claude-spawner.js +6 -1
- package/dist/config.d.ts +16 -0
- package/dist/config.js +5 -0
- package/dist/heartbeat-manager.d.ts +19 -0
- package/dist/heartbeat-manager.js +101 -0
- package/dist/poller.js +100 -12
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
package/dist/claude-spawner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
113
|
-
|
|
114
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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