@teamvibe/poller 0.1.45 → 0.1.46
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 +12 -0
- package/dist/config.js +4 -0
- package/dist/heartbeat-manager.d.ts +19 -0
- package/dist/heartbeat-manager.js +101 -0
- package/dist/poller.js +12 -3
- 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
|
@@ -22,6 +22,9 @@ declare const configSchema: z.ZodObject<{
|
|
|
22
22
|
BASE_BRAIN_PATH: z.ZodDefault<z.ZodString>;
|
|
23
23
|
BRAIN_AUTO_UPDATE: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
24
24
|
BRAIN_UPDATE_COOLDOWN_MS: z.ZodDefault<z.ZodNumber>;
|
|
25
|
+
MAINTENANCE_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
26
|
+
MAINTENANCE_CHECK_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
|
|
27
|
+
MAINTENANCE_ENABLED: z.ZodEffects<z.ZodDefault<z.ZodString>, boolean, string | undefined>;
|
|
25
28
|
}, "strip", z.ZodTypeAny, {
|
|
26
29
|
AWS_REGION: string;
|
|
27
30
|
TEAMVIBE_API_URL: string;
|
|
@@ -41,6 +44,9 @@ declare const configSchema: z.ZodObject<{
|
|
|
41
44
|
BASE_BRAIN_PATH: string;
|
|
42
45
|
BRAIN_AUTO_UPDATE: boolean;
|
|
43
46
|
BRAIN_UPDATE_COOLDOWN_MS: number;
|
|
47
|
+
MAINTENANCE_INTERVAL_MS: number;
|
|
48
|
+
MAINTENANCE_CHECK_INTERVAL_MS: number;
|
|
49
|
+
MAINTENANCE_ENABLED: boolean;
|
|
44
50
|
SQS_QUEUE_URL?: string | undefined;
|
|
45
51
|
SESSIONS_TABLE?: string | undefined;
|
|
46
52
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
|
@@ -66,6 +72,9 @@ declare const configSchema: z.ZodObject<{
|
|
|
66
72
|
BASE_BRAIN_PATH?: string | undefined;
|
|
67
73
|
BRAIN_AUTO_UPDATE?: string | undefined;
|
|
68
74
|
BRAIN_UPDATE_COOLDOWN_MS?: number | undefined;
|
|
75
|
+
MAINTENANCE_INTERVAL_MS?: number | undefined;
|
|
76
|
+
MAINTENANCE_CHECK_INTERVAL_MS?: number | undefined;
|
|
77
|
+
MAINTENANCE_ENABLED?: string | undefined;
|
|
69
78
|
}>;
|
|
70
79
|
export type Config = z.infer<typeof configSchema>;
|
|
71
80
|
export declare function loadConfig(): Config;
|
|
@@ -88,6 +97,9 @@ export declare const config: {
|
|
|
88
97
|
BASE_BRAIN_PATH: string;
|
|
89
98
|
BRAIN_AUTO_UPDATE: boolean;
|
|
90
99
|
BRAIN_UPDATE_COOLDOWN_MS: number;
|
|
100
|
+
MAINTENANCE_INTERVAL_MS: number;
|
|
101
|
+
MAINTENANCE_CHECK_INTERVAL_MS: number;
|
|
102
|
+
MAINTENANCE_ENABLED: boolean;
|
|
91
103
|
SQS_QUEUE_URL?: string | undefined;
|
|
92
104
|
SESSIONS_TABLE?: string | undefined;
|
|
93
105
|
TEAMVIBE_POLLER_TOKEN?: string | undefined;
|
package/dist/config.js
CHANGED
|
@@ -37,6 +37,10 @@ const configSchema = z.object({
|
|
|
37
37
|
.default('true')
|
|
38
38
|
.transform((v) => v.toLowerCase() === 'true'),
|
|
39
39
|
BRAIN_UPDATE_COOLDOWN_MS: z.coerce.number().default(300000), // 5 minutes
|
|
40
|
+
// Maintenance heartbeat
|
|
41
|
+
MAINTENANCE_INTERVAL_MS: z.coerce.number().default(21600000), // 6 hours between maintenance per brain
|
|
42
|
+
MAINTENANCE_CHECK_INTERVAL_MS: z.coerce.number().default(300000), // 5 min check loop
|
|
43
|
+
MAINTENANCE_ENABLED: z.string().default('true').transform((v) => v.toLowerCase() === 'true'),
|
|
40
44
|
});
|
|
41
45
|
export function loadConfig() {
|
|
42
46
|
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
|
|
@@ -65,7 +66,7 @@ async function processMessage(received) {
|
|
|
65
66
|
const threadId = queueMessage.thread_id;
|
|
66
67
|
const sessionLog = logger.createSession(logSessionId);
|
|
67
68
|
// Fetch user info if we only have ID
|
|
68
|
-
if (queueMessage.source !== 'cron' &&
|
|
69
|
+
if (queueMessage.source !== 'cron' && queueMessage.source !== 'heartbeat' &&
|
|
69
70
|
queueMessage.sender.name === 'Unknown User' &&
|
|
70
71
|
queueMessage.sender.id !== 'unknown') {
|
|
71
72
|
const userInfo = await getUserInfo(queueMessage.teamvibe.botToken, queueMessage.sender.id);
|
|
@@ -96,9 +97,13 @@ async function processMessage(received) {
|
|
|
96
97
|
sessionLog.error(`Failed to send Slack error: ${slackError}`);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
|
-
|
|
100
|
+
if (receiptHandle) {
|
|
101
|
+
await deleteMessage(receiptHandle);
|
|
102
|
+
}
|
|
100
103
|
return;
|
|
101
104
|
}
|
|
105
|
+
// Register brain for heartbeat tracking
|
|
106
|
+
registerBrain(queueMessage);
|
|
102
107
|
// Try to acquire session lock
|
|
103
108
|
let lockResult = await acquireSessionLock(threadId, kbPath);
|
|
104
109
|
if (!lockResult.success) {
|
|
@@ -160,7 +165,9 @@ async function processMessage(received) {
|
|
|
160
165
|
await releaseSessionLock(threadId, lockToken, 'idle');
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
|
-
|
|
168
|
+
if (receiptHandle) {
|
|
169
|
+
await deleteMessage(receiptHandle);
|
|
170
|
+
}
|
|
164
171
|
sessionLog.info('Message processed and deleted');
|
|
165
172
|
}
|
|
166
173
|
catch (error) {
|
|
@@ -238,6 +245,7 @@ async function shutdown(signal) {
|
|
|
238
245
|
shuttingDown = true;
|
|
239
246
|
logger.info(`${signal} received, shutting down gracefully...`);
|
|
240
247
|
stopRefresh();
|
|
248
|
+
stopHeartbeatLoop();
|
|
241
249
|
// Unblock all messages waiting on thread completion so they can exit
|
|
242
250
|
for (const [threadId, signals] of threadCompletionSignals.entries()) {
|
|
243
251
|
logger.info(`Unblocking ${signals.length} waiting message(s) on thread ${threadId}`);
|
|
@@ -277,5 +285,6 @@ export async function startPoller() {
|
|
|
277
285
|
}
|
|
278
286
|
await ensureDirectories();
|
|
279
287
|
await ensureBaseBrain();
|
|
288
|
+
startHeartbeatLoop(processMessage);
|
|
280
289
|
await pollLoop();
|
|
281
290
|
}
|
package/dist/types.d.ts
CHANGED