@teamvibe/poller 0.1.58 → 0.1.59

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.
@@ -1,4 +1,4 @@
1
- import type { TeamVibeQueueMessage } from './types.js';
1
+ import type { TeamVibeQueueMessage, SessionMetrics } from './types.js';
2
2
  import { type SessionLogger } from './logger.js';
3
3
  import type { TypingIndicator } from './slack-client.js';
4
4
  export interface SpawnResult {
@@ -6,7 +6,9 @@ export interface SpawnResult {
6
6
  output: string;
7
7
  exitCode: number | null;
8
8
  error?: string;
9
+ metrics?: SessionMetrics;
9
10
  }
11
+ export declare function buildPrompt(msg: TeamVibeQueueMessage, resumeHint?: string): string;
10
12
  export declare function spawnClaudeCode(msg: TeamVibeQueueMessage, sessionLog: SessionLogger, cwd: string, sessionId?: string, isFirstMessage?: boolean, lastMessageTs?: string, onMessageSent?: () => void, typingIndicator?: TypingIndicator): Promise<SpawnResult & {
11
13
  newSessionId?: string;
12
14
  }>;
@@ -103,7 +103,7 @@ function readBrainSettings(cwd) {
103
103
  return {};
104
104
  }
105
105
  }
106
- function buildPrompt(msg, resumeHint) {
106
+ export function buildPrompt(msg, resumeHint) {
107
107
  // Heartbeat messages get a minimal prompt — CLAUDE.md/MAINTENANCE.md handle the rest
108
108
  if (msg.source === 'heartbeat') {
109
109
  return 'heartbeat';
@@ -271,6 +271,8 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
271
271
  args.push('--model', model);
272
272
  sessionLog.info(`Using model: ${model}`);
273
273
  }
274
+ // Mailbox protocol: instruct Claude to check for mid-task messages
275
+ args.push('--append-system-prompt', 'Between major steps, check for new user messages: run `ls .mailbox/new/ 2>/dev/null`. If files exist, read each (oldest first), delete it, and act on the message immediately. If empty, continue your current work. Check before starting, every 2-3 tool calls, and after finishing.');
274
276
  // Handle session continuity
275
277
  if (sessionId) {
276
278
  if (isFirstMessage) {
@@ -322,6 +324,8 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
322
324
  let stdout = '';
323
325
  let stderr = '';
324
326
  let stdoutBuffer = '';
327
+ let metrics;
328
+ let lastActivityTime = Date.now();
325
329
  proc.stdout.on('data', (data) => {
326
330
  const chunk = data.toString();
327
331
  stdout += chunk;
@@ -335,6 +339,26 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
335
339
  for (const line of lines) {
336
340
  try {
337
341
  const event = JSON.parse(line);
342
+ // Track activity for idle watchdog
343
+ lastActivityTime = Date.now();
344
+ // Extract metrics from result event
345
+ if (event.type === 'result') {
346
+ metrics = {
347
+ duration_ms: event.duration_ms || 0,
348
+ duration_api_ms: event.duration_api_ms || 0,
349
+ num_turns: event.num_turns || 0,
350
+ total_cost_usd: event.total_cost_usd || 0,
351
+ input_tokens: event.usage?.input_tokens || 0,
352
+ output_tokens: event.usage?.output_tokens || 0,
353
+ cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
354
+ cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
355
+ model: Object.keys(event.modelUsage || {})[0] || 'unknown',
356
+ };
357
+ sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd.toFixed(4)}, ` +
358
+ `Duration: ${metrics.duration_ms}ms, ` +
359
+ `Turns: ${metrics.num_turns}, ` +
360
+ `Tokens: ${metrics.input_tokens}in/${metrics.output_tokens}out`);
361
+ }
338
362
  const formatted = formatStreamEvent(event);
339
363
  if (formatted) {
340
364
  sessionLog.claude('stdout', formatted);
@@ -383,13 +407,40 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
383
407
  proc.kill('SIGTERM');
384
408
  setTimeout(() => proc.kill('SIGKILL'), 5000);
385
409
  }, config.CLAUDE_TIMEOUT_MS);
410
+ // Idle watchdog: kill if no stdout activity for too long
411
+ let idleWarned = false;
412
+ const idleWatchdog = setInterval(() => {
413
+ const idleMs = Date.now() - lastActivityTime;
414
+ if (idleMs > config.IDLE_WATCHDOG_KILL_MS) {
415
+ const idleMin = Math.round(idleMs / 60000);
416
+ logger.error(`Claude Code idle for ${idleMin} minutes, killing process`);
417
+ emitError('session', `Claude Code idle for ${idleMin} minutes — killing hung process`, {
418
+ context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
419
+ channelId: msg.teamvibe.channelId,
420
+ });
421
+ proc.kill('SIGTERM');
422
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
423
+ }
424
+ else if (!idleWarned && idleMs > config.IDLE_WATCHDOG_WARN_MS) {
425
+ const idleMin = Math.round(idleMs / 60000);
426
+ logger.warn(`Claude Code idle for ${idleMin} minutes (will kill at ${Math.round(config.IDLE_WATCHDOG_KILL_MS / 60000)} min)`);
427
+ emitError('session', `Claude Code idle for ${idleMin} minutes`, {
428
+ severity: 'warn',
429
+ context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
430
+ channelId: msg.teamvibe.channelId,
431
+ });
432
+ idleWarned = true;
433
+ }
434
+ }, 30000); // Check every 30 seconds
386
435
  proc.on('close', (code) => {
387
436
  clearTimeout(timeout);
437
+ clearInterval(idleWatchdog);
388
438
  activeProcesses.delete(msg.id);
389
439
  const result = {
390
440
  success: code === 0,
391
441
  output: stdout,
392
442
  exitCode: code,
443
+ metrics,
393
444
  };
394
445
  if (timedOut) {
395
446
  result.error = `Session timed out after ${timeoutMinutes} minutes. The task was still running when the time limit was reached. Consider breaking complex tasks into smaller steps.`;
@@ -401,6 +452,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
401
452
  });
402
453
  proc.on('error', (error) => {
403
454
  clearTimeout(timeout);
455
+ clearInterval(idleWatchdog);
404
456
  activeProcesses.delete(msg.id);
405
457
  emitError('session', `Claude Code process spawn error: ${error.message}`, {
406
458
  channelId: msg.teamvibe.channelId,
package/dist/config.d.ts CHANGED
@@ -13,6 +13,8 @@ declare const configSchema: z.ZodObject<{
13
13
  CLAUDE_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
14
14
  STALE_LOCK_TIMEOUT_MS: z.ZodDefault<z.ZodNumber>;
15
15
  LOCK_RETRY_INTERVAL_MS: z.ZodDefault<z.ZodNumber>;
16
+ IDLE_WATCHDOG_WARN_MS: z.ZodDefault<z.ZodNumber>;
17
+ IDLE_WATCHDOG_KILL_MS: z.ZodDefault<z.ZodNumber>;
16
18
  TEAMVIBE_DATA_DIR: z.ZodDefault<z.ZodString>;
17
19
  BRAINS_PATH: z.ZodDefault<z.ZodString>;
18
20
  DEFAULT_BRAIN_PATH: z.ZodDefault<z.ZodString>;
@@ -37,6 +39,8 @@ declare const configSchema: z.ZodObject<{
37
39
  CLAUDE_TIMEOUT_MS: number;
38
40
  STALE_LOCK_TIMEOUT_MS: number;
39
41
  LOCK_RETRY_INTERVAL_MS: number;
42
+ IDLE_WATCHDOG_WARN_MS: number;
43
+ IDLE_WATCHDOG_KILL_MS: number;
40
44
  TEAMVIBE_DATA_DIR: string;
41
45
  BRAINS_PATH: string;
42
46
  DEFAULT_BRAIN_PATH: string;
@@ -67,6 +71,8 @@ declare const configSchema: z.ZodObject<{
67
71
  CLAUDE_TIMEOUT_MS?: number | undefined;
68
72
  STALE_LOCK_TIMEOUT_MS?: number | undefined;
69
73
  LOCK_RETRY_INTERVAL_MS?: number | undefined;
74
+ IDLE_WATCHDOG_WARN_MS?: number | undefined;
75
+ IDLE_WATCHDOG_KILL_MS?: number | undefined;
70
76
  TEAMVIBE_DATA_DIR?: string | undefined;
71
77
  BRAINS_PATH?: string | undefined;
72
78
  DEFAULT_BRAIN_PATH?: string | undefined;
@@ -94,6 +100,8 @@ export declare const config: {
94
100
  CLAUDE_TIMEOUT_MS: number;
95
101
  STALE_LOCK_TIMEOUT_MS: number;
96
102
  LOCK_RETRY_INTERVAL_MS: number;
103
+ IDLE_WATCHDOG_WARN_MS: number;
104
+ IDLE_WATCHDOG_KILL_MS: number;
97
105
  TEAMVIBE_DATA_DIR: string;
98
106
  BRAINS_PATH: string;
99
107
  DEFAULT_BRAIN_PATH: string;
package/dist/config.js CHANGED
@@ -22,6 +22,8 @@ const configSchema = z.object({
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
24
  LOCK_RETRY_INTERVAL_MS: z.coerce.number().default(30000), // 30 seconds
25
+ IDLE_WATCHDOG_WARN_MS: z.coerce.number().default(300000), // 5 minutes
26
+ IDLE_WATCHDOG_KILL_MS: z.coerce.number().default(600000), // 10 minutes
25
27
  // Paths
26
28
  TEAMVIBE_DATA_DIR: z.string().default(DEFAULT_DATA_DIR),
27
29
  BRAINS_PATH: z.string().default(''),
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Write a new message to the mailbox.
3
+ * Returns the filename of the created message file.
4
+ */
5
+ export declare function writeMessage(brainPath: string, content: string): string;
6
+ /**
7
+ * Pick the oldest message from processing/ (crash recovery) or new/.
8
+ * If found in new/, moves it to processing/.
9
+ * Returns { filename, content } or null if no messages.
10
+ */
11
+ export declare function pickNextMessage(brainPath: string): {
12
+ filename: string;
13
+ content: string;
14
+ } | null;
15
+ /**
16
+ * Delete a message file from processing/ (marks it as done).
17
+ */
18
+ export declare function markProcessed(brainPath: string, filename: string): void;
19
+ /**
20
+ * Check if there are unprocessed messages in new/ or processing/.
21
+ */
22
+ export declare function hasUnprocessed(brainPath: string): boolean;
@@ -0,0 +1,86 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { logger } from './logger.js';
4
+ const MAILBOX_DIR = '.mailbox';
5
+ const NEW_DIR = 'new';
6
+ const PROCESSING_DIR = 'processing';
7
+ function ensureMailboxDirs(brainPath) {
8
+ const mailbox = join(brainPath, MAILBOX_DIR);
9
+ const newDir = join(mailbox, NEW_DIR);
10
+ const processingDir = join(mailbox, PROCESSING_DIR);
11
+ mkdirSync(newDir, { recursive: true });
12
+ mkdirSync(processingDir, { recursive: true });
13
+ }
14
+ /**
15
+ * Write a new message to the mailbox.
16
+ * Returns the filename of the created message file.
17
+ */
18
+ export function writeMessage(brainPath, content) {
19
+ ensureMailboxDirs(brainPath);
20
+ const timestamp = process.hrtime.bigint().toString();
21
+ const filename = `${timestamp}.txt`;
22
+ const filepath = join(brainPath, MAILBOX_DIR, NEW_DIR, filename);
23
+ writeFileSync(filepath, content, 'utf-8');
24
+ logger.info(`[Mailbox] Wrote message: ${filename} (${content.length} chars)`);
25
+ return filename;
26
+ }
27
+ /**
28
+ * Pick the oldest message from processing/ (crash recovery) or new/.
29
+ * If found in new/, moves it to processing/.
30
+ * Returns { filename, content } or null if no messages.
31
+ */
32
+ export function pickNextMessage(brainPath) {
33
+ ensureMailboxDirs(brainPath);
34
+ const processingDir = join(brainPath, MAILBOX_DIR, PROCESSING_DIR);
35
+ const newDir = join(brainPath, MAILBOX_DIR, NEW_DIR);
36
+ // First check processing/ (crash recovery)
37
+ const processingFiles = readdirSync(processingDir).filter(f => f.endsWith('.txt')).sort();
38
+ if (processingFiles.length > 0) {
39
+ const filename = processingFiles[0];
40
+ const content = readFileSync(join(processingDir, filename), 'utf-8');
41
+ logger.info(`[Mailbox] Resuming processing message: ${filename}`);
42
+ return { filename, content };
43
+ }
44
+ // Then check new/
45
+ const newFiles = readdirSync(newDir).filter(f => f.endsWith('.txt')).sort();
46
+ if (newFiles.length > 0) {
47
+ const filename = newFiles[0];
48
+ const srcPath = join(newDir, filename);
49
+ const dstPath = join(processingDir, filename);
50
+ const content = readFileSync(srcPath, 'utf-8');
51
+ renameSync(srcPath, dstPath);
52
+ logger.info(`[Mailbox] Picked message: ${filename} (moved to processing)`);
53
+ return { filename, content };
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * Delete a message file from processing/ (marks it as done).
59
+ */
60
+ export function markProcessed(brainPath, filename) {
61
+ const filepath = join(brainPath, MAILBOX_DIR, PROCESSING_DIR, filename);
62
+ try {
63
+ if (existsSync(filepath)) {
64
+ unlinkSync(filepath);
65
+ logger.info(`[Mailbox] Deleted processed message: ${filename}`);
66
+ }
67
+ }
68
+ catch (error) {
69
+ logger.warn(`[Mailbox] Failed to delete ${filename}: ${error}`);
70
+ }
71
+ }
72
+ /**
73
+ * Check if there are unprocessed messages in new/ or processing/.
74
+ */
75
+ export function hasUnprocessed(brainPath) {
76
+ const newDir = join(brainPath, MAILBOX_DIR, NEW_DIR);
77
+ const processingDir = join(brainPath, MAILBOX_DIR, PROCESSING_DIR);
78
+ try {
79
+ const newCount = existsSync(newDir) ? readdirSync(newDir).filter(f => f.endsWith('.txt')).length : 0;
80
+ const procCount = existsSync(processingDir) ? readdirSync(processingDir).filter(f => f.endsWith('.txt')).length : 0;
81
+ return (newCount + procCount) > 0;
82
+ }
83
+ catch {
84
+ return false;
85
+ }
86
+ }
package/dist/poller.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { config } from './config.js';
2
2
  import { logger } from './logger.js';
3
- import { pollMessages, deleteMessage, extendVisibility, } from './sqs-poller.js';
4
- import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses } from './claude-spawner.js';
3
+ import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
+ import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses, buildPrompt } from './claude-spawner.js';
5
5
  import { sendSlackError, addReaction, getUserInfo, startTypingIndicator } from './slack-client.js';
6
6
  import { acquireSessionLock, releaseSessionLock, updateSessionId } from './session-store.js';
7
7
  import { getBrainPath, ensureDirectories, ensureBaseBrain, pushBrainChanges } from './brain-manager.js';
@@ -9,79 +9,93 @@ import { postMaintenanceReports } from './maintenance-reporter.js';
9
9
  import { initAuth, stopRefresh } from './auth-provider.js';
10
10
  import { registerBrain, startHeartbeatLoop, stopHeartbeatLoop } from './heartbeat-manager.js';
11
11
  import { startEventQueue, stopEventQueue, flush as flushEventQueue } from './event-queue.js';
12
+ import { writeMessage, pickNextMessage, markProcessed, hasUnprocessed } from './mailbox-manager.js';
12
13
  // Track active message processing
13
14
  const processingMessages = new Set();
14
- // Per-thread completion signals
15
- const threadCompletionSignals = new Map();
16
- const waitingCountByThread = new Map();
17
- function getQueueStats() {
18
- let totalWaiting = 0;
19
- waitingCountByThread.forEach((count) => {
20
- totalWaiting += count;
21
- });
22
- return {
23
- processing: processingMessages.size,
24
- threadsWithWaiting: waitingCountByThread.size,
25
- totalWaiting,
26
- };
27
- }
28
- function logQueueState(context) {
29
- const stats = getQueueStats();
30
- logger.info(`[Queue] ${context} | Processing: ${stats.processing}, Threads with waiting: ${stats.threadsWithWaiting}, Total waiting: ${stats.totalWaiting}`);
31
- }
32
- function waitForThreadCompletion(threadId) {
33
- let resolveFn;
34
- const promise = new Promise((resolve) => {
35
- resolveFn = resolve;
36
- const signals = threadCompletionSignals.get(threadId) || [];
37
- signals.push(resolve);
38
- threadCompletionSignals.set(threadId, signals);
39
- const currentCount = waitingCountByThread.get(threadId) || 0;
40
- waitingCountByThread.set(threadId, currentCount + 1);
41
- logger.info(`[Queue] Thread ${threadId}: ${currentCount + 1} message(s) now waiting`);
42
- logQueueState('After enqueue');
43
- });
44
- const cancel = () => {
45
- // Remove this specific resolve from the signals array
46
- const signals = threadCompletionSignals.get(threadId);
47
- if (signals) {
48
- const idx = signals.indexOf(resolveFn);
49
- if (idx !== -1)
50
- signals.splice(idx, 1);
51
- if (signals.length === 0)
52
- threadCompletionSignals.delete(threadId);
15
+ // Track which threads have an active processing loop
16
+ const activeLoops = new Set();
17
+ async function runMailboxLoop(threadId, kbPath, queueMessage, sessionLog) {
18
+ if (activeLoops.has(threadId)) {
19
+ sessionLog.info(`Loop already running for thread ${threadId}, skipping`);
20
+ return;
21
+ }
22
+ activeLoops.add(threadId);
23
+ let iteration = 0;
24
+ try {
25
+ let lockResult = await acquireSessionLock(threadId, kbPath);
26
+ if (!lockResult.success) {
27
+ sessionLog.info(`Session ${threadId} is locked, mailbox message will be picked up by active session`);
28
+ return;
53
29
  }
54
- const count = waitingCountByThread.get(threadId) || 0;
55
- if (count <= 1) {
56
- waitingCountByThread.delete(threadId);
30
+ const { session, lockToken } = lockResult;
31
+ while (true) {
32
+ const message = pickNextMessage(kbPath);
33
+ if (!message) {
34
+ sessionLog.info(`[Loop] No more messages for thread ${threadId}`);
35
+ break;
36
+ }
37
+ iteration++;
38
+ const isFirstMessage = session.message_count === 1 && iteration === 1;
39
+ sessionLog.info(`[Loop] Iteration #${iteration} — processing: ${message.filename}`);
40
+ await ensureBaseBrain();
41
+ const hasSlackContext = Boolean(queueMessage.response_context.slack?.channel &&
42
+ queueMessage.response_context.slack?.message_ts);
43
+ const typing = hasSlackContext
44
+ ? startTypingIndicator(queueMessage)
45
+ : undefined;
46
+ try {
47
+ // Build a temporary message with the mailbox content as text
48
+ const mailboxMsg = {
49
+ ...queueMessage,
50
+ text: message.content,
51
+ };
52
+ const result = await spawnClaudeCode(mailboxMsg, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
53
+ typing?.stop();
54
+ if (result.newSessionId && lockToken) {
55
+ await updateSessionId(threadId, lockToken, result.newSessionId);
56
+ }
57
+ if (result.metrics) {
58
+ sessionLog.info(`[Loop] Iteration #${iteration} complete — ` +
59
+ `cost: $${result.metrics.total_cost_usd.toFixed(4)}, ` +
60
+ `duration: ${result.metrics.duration_ms}ms`);
61
+ }
62
+ if (result.success) {
63
+ sessionLog.info(`[Loop] Claude completed iteration #${iteration}`);
64
+ if (queueMessage.teamvibe.brain?.brainId) {
65
+ await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId, queueMessage.teamvibe.workspaceId);
66
+ }
67
+ await postMaintenanceReports(kbPath, queueMessage.teamvibe.channelId);
68
+ }
69
+ else {
70
+ sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
71
+ if (hasSlackContext) {
72
+ const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
73
+ const isTimeout = errorMessage.includes('timed out');
74
+ await sendSlackError(queueMessage, errorMessage);
75
+ await addReaction(queueMessage, isTimeout ? 'hourglass' : 'x');
76
+ }
77
+ }
78
+ }
79
+ catch (error) {
80
+ typing?.stop();
81
+ sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
82
+ }
83
+ // Safety net: ensure the processing file is cleaned up
84
+ markProcessed(kbPath, message.filename);
85
+ if (!hasUnprocessed(kbPath)) {
86
+ break;
87
+ }
88
+ sessionLog.info(`[Loop] More messages found, continuing...`);
57
89
  }
58
- else {
59
- waitingCountByThread.set(threadId, count - 1);
90
+ if (lockToken) {
91
+ const lastMessageTs = queueMessage.response_context.slack?.message_ts;
92
+ await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
60
93
  }
61
- };
62
- return { promise, cancel };
63
- }
64
- function signalThreadCompletion(threadId) {
65
- const signals = threadCompletionSignals.get(threadId);
66
- const waitingCount = signals?.length || 0;
67
- if (signals && signals.length > 0) {
68
- logger.info(`[Queue] Thread ${threadId} completed, waking ${waitingCount} waiting message(s)`);
69
- signals.forEach((resolve) => resolve());
70
- threadCompletionSignals.delete(threadId);
71
- waitingCountByThread.delete(threadId);
72
- logQueueState('After signal');
94
+ sessionLog.info(`[Loop] Complete. Iterations: ${iteration}`);
95
+ }
96
+ finally {
97
+ activeLoops.delete(threadId);
73
98
  }
74
- }
75
- function startHeartbeat(receiptHandle, sessionLog) {
76
- return setInterval(async () => {
77
- try {
78
- await extendVisibility(receiptHandle, config.VISIBILITY_TIMEOUT_SECONDS);
79
- sessionLog.info(`Heartbeat: extended visibility by ${config.VISIBILITY_TIMEOUT_SECONDS}s`);
80
- }
81
- catch (error) {
82
- sessionLog.error(`Heartbeat failed: ${error instanceof Error ? error.message : error}`);
83
- }
84
- }, config.HEARTBEAT_INTERVAL_MS);
85
99
  }
86
100
  async function processMessage(received) {
87
101
  const { queueMessage, receiptHandle, messageId } = received;
@@ -127,99 +141,21 @@ async function processMessage(received) {
127
141
  }
128
142
  // Register brain for heartbeat tracking
129
143
  registerBrain(queueMessage);
130
- // Try to acquire session lock
131
- let lockResult = await acquireSessionLock(threadId, kbPath);
132
- if (!lockResult.success) {
133
- sessionLog.info(`Session ${threadId} is processing, waiting for completion...`);
134
- const waitHeartbeat = startHeartbeat(receiptHandle, sessionLog);
135
- try {
136
- const maxWaitMs = config.STALE_LOCK_TIMEOUT_MS;
137
- const retryIntervalMs = config.LOCK_RETRY_INTERVAL_MS;
138
- const waitStart = Date.now();
139
- while (Date.now() - waitStart < maxWaitMs) {
140
- const waiter = waitForThreadCompletion(threadId);
141
- // Wait for either: completion signal OR retry interval timeout
142
- await Promise.race([
143
- waiter.promise,
144
- sleep(retryIntervalMs),
145
- ]);
146
- // Clean up the waiter (handles the timeout case where signal didn't fire)
147
- waiter.cancel();
148
- lockResult = await acquireSessionLock(threadId, kbPath);
149
- if (lockResult.success)
150
- break;
151
- sessionLog.info(`Lock still held for ${threadId}, retrying in ${retryIntervalMs / 1000}s...`);
152
- }
153
- if (!lockResult.success) {
154
- sessionLog.error(`Gave up waiting for lock on ${threadId} after ${maxWaitMs / 1000}s`);
155
- await deleteMessage(receiptHandle);
156
- return;
157
- }
158
- }
159
- finally {
160
- clearInterval(waitHeartbeat);
161
- }
144
+ // Build prompt and write to mailbox
145
+ const prompt = buildPrompt(queueMessage);
146
+ writeMessage(kbPath, prompt);
147
+ sessionLog.info(`Wrote message to mailbox (${prompt.length} chars)`);
148
+ // Delete SQS message immediately — it's now persisted in the mailbox
149
+ if (receiptHandle) {
150
+ await deleteMessage(receiptHandle);
151
+ sessionLog.info('SQS message deleted (persisted in mailbox)');
162
152
  }
163
- const { session, lockToken } = lockResult;
164
- const isFirstMessage = session.message_count === 1;
165
- sessionLog.info(`Acquired lock on session ${threadId}, Claude session: ${session.session_id}, mode: ${isFirstMessage ? 'new' : 'resume'}, message #${session.message_count}`);
166
- logQueueState(`Lock acquired for ${threadId}`);
167
153
  processingMessages.add(messageId);
168
- const heartbeat = startHeartbeat(receiptHandle, sessionLog);
169
- // Start typing indicator
170
- const typing = hasSlackContext
171
- ? startTypingIndicator(queueMessage)
172
- : undefined;
173
154
  try {
174
- // Refresh base brain if cooldown has elapsed (5 min default)
175
- await ensureBaseBrain();
176
- const result = await spawnClaudeCode(queueMessage, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
177
- typing?.stop();
178
- // If Claude generated a new session ID (stale lock recovery), persist it
179
- if (result.newSessionId && lockToken) {
180
- await updateSessionId(threadId, lockToken, result.newSessionId);
181
- }
182
- if (result.success) {
183
- sessionLog.info('Claude Code completed successfully');
184
- // Push any changes in the channel brain repo
185
- if (queueMessage.teamvibe.brain?.brainId) {
186
- await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId, queueMessage.teamvibe.workspaceId);
187
- }
188
- // Post any pending maintenance reports (fire-and-forget)
189
- await postMaintenanceReports(kbPath, queueMessage.teamvibe.channelId);
190
- if (lockToken) {
191
- const lastMessageTs = queueMessage.response_context.slack?.message_ts;
192
- await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
193
- }
194
- }
195
- else {
196
- sessionLog.error(`Claude Code failed: ${result.error}`);
197
- if (hasSlackContext) {
198
- const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
199
- const isTimeout = errorMessage.includes('timed out');
200
- await sendSlackError(queueMessage, errorMessage);
201
- await addReaction(queueMessage, isTimeout ? 'hourglass' : 'x');
202
- }
203
- if (lockToken) {
204
- await releaseSessionLock(threadId, lockToken, 'idle');
205
- }
206
- }
207
- if (receiptHandle) {
208
- await deleteMessage(receiptHandle);
209
- }
210
- sessionLog.info('Message processed and deleted');
155
+ await runMailboxLoop(threadId, kbPath, queueMessage, sessionLog);
211
156
  }
212
157
  catch (error) {
213
- typing?.stop();
214
- sessionLog.error(`Error processing message: ${error instanceof Error ? error.message : error}`);
215
- if (lockToken) {
216
- try {
217
- await releaseSessionLock(threadId, lockToken, 'idle');
218
- }
219
- catch (releaseError) {
220
- sessionLog.error(`Failed to release lock: ${releaseError}`);
221
- }
222
- }
158
+ sessionLog.error(`Error in mailbox loop: ${error instanceof Error ? error.message : error}`);
223
159
  if (hasSlackContext) {
224
160
  try {
225
161
  const errMsg = error instanceof Error ? error.message : 'Unknown error';
@@ -239,9 +175,7 @@ async function processMessage(received) {
239
175
  throw error;
240
176
  }
241
177
  finally {
242
- clearInterval(heartbeat);
243
178
  processingMessages.delete(messageId);
244
- signalThreadCompletion(threadId);
245
179
  }
246
180
  }
247
181
  function deduplicateMessages(messages) {
@@ -279,7 +213,6 @@ async function pollLoop() {
279
213
  continue;
280
214
  }
281
215
  logger.info(`Received ${messages.length} message(s) from SQS`);
282
- logQueueState('After SQS poll');
283
216
  // Deduplicate: if multiple messages for the same thread, keep only the latest
284
217
  const { unique, duplicates } = deduplicateMessages(messages);
285
218
  for (const dup of duplicates) {
@@ -314,13 +247,6 @@ async function shutdown(signal) {
314
247
  stopRefresh();
315
248
  stopHeartbeatLoop();
316
249
  stopEventQueue();
317
- // Unblock all messages waiting on thread completion so they can exit
318
- for (const [threadId, signals] of threadCompletionSignals.entries()) {
319
- logger.info(`Unblocking ${signals.length} waiting message(s) on thread ${threadId}`);
320
- signals.forEach((resolve) => resolve());
321
- }
322
- threadCompletionSignals.clear();
323
- waitingCountByThread.clear();
324
250
  // Kill all active Claude child processes
325
251
  await killAllActiveProcesses();
326
252
  // Wait for processingMessages to drain (with timeout)
@@ -357,21 +283,5 @@ export async function startPoller() {
357
283
  await ensureDirectories();
358
284
  await ensureBaseBrain();
359
285
  startHeartbeatLoop(processMessage);
360
- // Periodic queue health check — surfaces stuck messages in logs
361
- const healthCheckInterval = setInterval(() => {
362
- const stats = getQueueStats();
363
- if (stats.totalWaiting > 0) {
364
- logger.warn(`[Queue Health] ${stats.totalWaiting} message(s) waiting across ${stats.threadsWithWaiting} thread(s), ` +
365
- `${stats.processing} actively processing`);
366
- for (const [threadId, count] of waitingCountByThread.entries()) {
367
- logger.warn(`[Queue Health] Thread ${threadId}: ${count} waiting`);
368
- }
369
- }
370
- }, 60000);
371
- try {
372
- await pollLoop();
373
- }
374
- finally {
375
- clearInterval(healthCheckInterval);
376
- }
286
+ await pollLoop();
377
287
  }
package/dist/types.d.ts CHANGED
@@ -67,3 +67,14 @@ export interface SessionRecord {
67
67
  ttl: number;
68
68
  }
69
69
  export type SessionStatus = 'processing' | 'idle' | 'waiting_for_input';
70
+ export interface SessionMetrics {
71
+ duration_ms: number;
72
+ duration_api_ms: number;
73
+ num_turns: number;
74
+ total_cost_usd: number;
75
+ input_tokens: number;
76
+ output_tokens: number;
77
+ cache_read_input_tokens: number;
78
+ cache_creation_input_tokens: number;
79
+ model: string;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.1.58",
3
+ "version": "0.1.59",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {