@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.
@@ -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
@@ -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
- await deleteMessage(receiptHandle);
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
- await deleteMessage(receiptHandle);
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
@@ -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.46",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {