@teamvibe/poller 0.1.58 → 0.1.61

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,17 @@ 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
+ // Inbox protocol: instruct Claude to check for mid-task messages (thread-scoped)
275
+ const inboxNewDir = `.inbox/${msg.thread_id}/new`;
276
+ args.push('--append-system-prompt', [
277
+ `CRITICAL — INBOX PROTOCOL: You MUST check for new user messages after EVERY tool call.`,
278
+ `Run: ls ${inboxNewDir}/ 2>/dev/null`,
279
+ `If files exist: read each file (oldest first), delete it, and act on the message IMMEDIATELY — it takes priority over your current task.`,
280
+ `If the message says "stop", finish your current step and stop.`,
281
+ `If empty: continue your work.`,
282
+ `You MUST check the inbox: (1) before starting work, (2) after EVERY tool call, (3) after finishing all work.`,
283
+ `Never skip an inbox check. This is how the user communicates with you during long tasks.`,
284
+ ].join('\n'));
274
285
  // Handle session continuity
275
286
  if (sessionId) {
276
287
  if (isFirstMessage) {
@@ -312,6 +323,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
312
323
  TEAMVIBE_CHANNEL_ID: msg.teamvibe.channelId,
313
324
  TEAMVIBE_BOT_ID: msg.teamvibe.botId,
314
325
  TEAMVIBE_POLLER_ID: getPollerId() || '',
326
+ INBOX_THREAD_ID: msg.thread_id,
315
327
  },
316
328
  stdio: ['pipe', 'pipe', 'pipe'],
317
329
  });
@@ -322,6 +334,8 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
322
334
  let stdout = '';
323
335
  let stderr = '';
324
336
  let stdoutBuffer = '';
337
+ let metrics;
338
+ let lastActivityTime = Date.now();
325
339
  proc.stdout.on('data', (data) => {
326
340
  const chunk = data.toString();
327
341
  stdout += chunk;
@@ -335,6 +349,26 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
335
349
  for (const line of lines) {
336
350
  try {
337
351
  const event = JSON.parse(line);
352
+ // Track activity for idle watchdog
353
+ lastActivityTime = Date.now();
354
+ // Extract metrics from result event
355
+ if (event.type === 'result') {
356
+ metrics = {
357
+ duration_ms: event.duration_ms || 0,
358
+ duration_api_ms: event.duration_api_ms || 0,
359
+ num_turns: event.num_turns || 0,
360
+ total_cost_usd: event.total_cost_usd || 0,
361
+ input_tokens: event.usage?.input_tokens || 0,
362
+ output_tokens: event.usage?.output_tokens || 0,
363
+ cache_read_input_tokens: event.usage?.cache_read_input_tokens || 0,
364
+ cache_creation_input_tokens: event.usage?.cache_creation_input_tokens || 0,
365
+ model: Object.keys(event.modelUsage || {})[0] || 'unknown',
366
+ };
367
+ sessionLog.info(`[Metrics] Cost: $${metrics.total_cost_usd.toFixed(4)}, ` +
368
+ `Duration: ${metrics.duration_ms}ms, ` +
369
+ `Turns: ${metrics.num_turns}, ` +
370
+ `Tokens: ${metrics.input_tokens}in/${metrics.output_tokens}out`);
371
+ }
338
372
  const formatted = formatStreamEvent(event);
339
373
  if (formatted) {
340
374
  sessionLog.claude('stdout', formatted);
@@ -383,13 +417,40 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
383
417
  proc.kill('SIGTERM');
384
418
  setTimeout(() => proc.kill('SIGKILL'), 5000);
385
419
  }, config.CLAUDE_TIMEOUT_MS);
420
+ // Idle watchdog: kill if no stdout activity for too long
421
+ let idleWarned = false;
422
+ const idleWatchdog = setInterval(() => {
423
+ const idleMs = Date.now() - lastActivityTime;
424
+ if (idleMs > config.IDLE_WATCHDOG_KILL_MS) {
425
+ const idleMin = Math.round(idleMs / 60000);
426
+ logger.error(`Claude Code idle for ${idleMin} minutes, killing process`);
427
+ emitError('session', `Claude Code idle for ${idleMin} minutes — killing hung process`, {
428
+ context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
429
+ channelId: msg.teamvibe.channelId,
430
+ });
431
+ proc.kill('SIGTERM');
432
+ setTimeout(() => proc.kill('SIGKILL'), 5000);
433
+ }
434
+ else if (!idleWarned && idleMs > config.IDLE_WATCHDOG_WARN_MS) {
435
+ const idleMin = Math.round(idleMs / 60000);
436
+ logger.warn(`Claude Code idle for ${idleMin} minutes (will kill at ${Math.round(config.IDLE_WATCHDOG_KILL_MS / 60000)} min)`);
437
+ emitError('session', `Claude Code idle for ${idleMin} minutes`, {
438
+ severity: 'warn',
439
+ context: { channelId: msg.teamvibe.channelId, threadId: msg.thread_id },
440
+ channelId: msg.teamvibe.channelId,
441
+ });
442
+ idleWarned = true;
443
+ }
444
+ }, 30000); // Check every 30 seconds
386
445
  proc.on('close', (code) => {
387
446
  clearTimeout(timeout);
447
+ clearInterval(idleWatchdog);
388
448
  activeProcesses.delete(msg.id);
389
449
  const result = {
390
450
  success: code === 0,
391
451
  output: stdout,
392
452
  exitCode: code,
453
+ metrics,
393
454
  };
394
455
  if (timedOut) {
395
456
  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 +462,7 @@ async function runClaudeCode(msg, sessionLog, cwd, sessionId, isFirstMessage = t
401
462
  });
402
463
  proc.on('error', (error) => {
403
464
  clearTimeout(timeout);
465
+ clearInterval(idleWatchdog);
404
466
  activeProcesses.delete(msg.id);
405
467
  emitError('session', `Claude Code process spawn error: ${error.message}`, {
406
468
  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 inbox.
3
+ * Returns the filename of the created message file.
4
+ */
5
+ export declare function writeMessage(brainPath: string, threadId: 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, threadId: 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, threadId: string, filename: string): void;
19
+ /**
20
+ * Check if there are unprocessed messages in new/ or processing/.
21
+ */
22
+ export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
@@ -0,0 +1,89 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, readdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { logger } from './logger.js';
4
+ const INBOX_DIR = '.inbox';
5
+ const NEW_DIR = 'new';
6
+ const PROCESSING_DIR = 'processing';
7
+ function getInboxPath(brainPath, threadId) {
8
+ return join(brainPath, INBOX_DIR, threadId);
9
+ }
10
+ function ensureInboxDirs(brainPath, threadId) {
11
+ const inbox = getInboxPath(brainPath, threadId);
12
+ mkdirSync(join(inbox, NEW_DIR), { recursive: true });
13
+ mkdirSync(join(inbox, PROCESSING_DIR), { recursive: true });
14
+ }
15
+ /**
16
+ * Write a new message to the inbox.
17
+ * Returns the filename of the created message file.
18
+ */
19
+ export function writeMessage(brainPath, threadId, content) {
20
+ ensureInboxDirs(brainPath, threadId);
21
+ const timestamp = process.hrtime.bigint().toString();
22
+ const filename = `${timestamp}.txt`;
23
+ const filepath = join(getInboxPath(brainPath, threadId), NEW_DIR, filename);
24
+ writeFileSync(filepath, content, 'utf-8');
25
+ logger.info(`[Inbox] Wrote message: ${filename} (${content.length} chars)`);
26
+ return filename;
27
+ }
28
+ /**
29
+ * Pick the oldest message from processing/ (crash recovery) or new/.
30
+ * If found in new/, moves it to processing/.
31
+ * Returns { filename, content } or null if no messages.
32
+ */
33
+ export function pickNextMessage(brainPath, threadId) {
34
+ ensureInboxDirs(brainPath, threadId);
35
+ const inbox = getInboxPath(brainPath, threadId);
36
+ const processingDir = join(inbox, PROCESSING_DIR);
37
+ const newDir = join(inbox, NEW_DIR);
38
+ // First check processing/ (crash recovery)
39
+ const processingFiles = readdirSync(processingDir).filter(f => f.endsWith('.txt')).sort();
40
+ if (processingFiles.length > 0) {
41
+ const filename = processingFiles[0];
42
+ const content = readFileSync(join(processingDir, filename), 'utf-8');
43
+ logger.info(`[Inbox] Resuming processing message: ${filename}`);
44
+ return { filename, content };
45
+ }
46
+ // Then check new/
47
+ const newFiles = readdirSync(newDir).filter(f => f.endsWith('.txt')).sort();
48
+ if (newFiles.length > 0) {
49
+ const filename = newFiles[0];
50
+ const srcPath = join(newDir, filename);
51
+ const dstPath = join(processingDir, filename);
52
+ const content = readFileSync(srcPath, 'utf-8');
53
+ renameSync(srcPath, dstPath);
54
+ logger.info(`[Inbox] Picked message: ${filename} (moved to processing)`);
55
+ return { filename, content };
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Delete a message file from processing/ (marks it as done).
61
+ */
62
+ export function markProcessed(brainPath, threadId, filename) {
63
+ const filepath = join(getInboxPath(brainPath, threadId), PROCESSING_DIR, filename);
64
+ try {
65
+ if (existsSync(filepath)) {
66
+ unlinkSync(filepath);
67
+ logger.info(`[Inbox] Deleted processed message: ${filename}`);
68
+ }
69
+ }
70
+ catch (error) {
71
+ logger.warn(`[Inbox] Failed to delete ${filename}: ${error}`);
72
+ }
73
+ }
74
+ /**
75
+ * Check if there are unprocessed messages in new/ or processing/.
76
+ */
77
+ export function hasUnprocessed(brainPath, threadId) {
78
+ const inbox = getInboxPath(brainPath, threadId);
79
+ const newDir = join(inbox, NEW_DIR);
80
+ const processingDir = join(inbox, PROCESSING_DIR);
81
+ try {
82
+ const newCount = existsSync(newDir) ? readdirSync(newDir).filter(f => f.endsWith('.txt')).length : 0;
83
+ const procCount = existsSync(processingDir) ? readdirSync(processingDir).filter(f => f.endsWith('.txt')).length : 0;
84
+ return (newCount + procCount) > 0;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
@@ -0,0 +1,26 @@
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, threadId: 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, threadId: 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, threadId: string, filename: string): void;
19
+ /**
20
+ * Check if there are unprocessed messages in new/ or processing/.
21
+ */
22
+ export declare function hasUnprocessed(brainPath: string, threadId: string): boolean;
23
+ /**
24
+ * Get the mailbox new/ directory path for use in Claude's system prompt.
25
+ */
26
+ export declare function getMailboxNewPath(brainPath: string, threadId: string): string;
@@ -0,0 +1,95 @@
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 getMailboxPath(brainPath, threadId) {
8
+ return join(brainPath, MAILBOX_DIR, threadId);
9
+ }
10
+ function ensureMailboxDirs(brainPath, threadId) {
11
+ const mailbox = getMailboxPath(brainPath, threadId);
12
+ mkdirSync(join(mailbox, NEW_DIR), { recursive: true });
13
+ mkdirSync(join(mailbox, PROCESSING_DIR), { recursive: true });
14
+ }
15
+ /**
16
+ * Write a new message to the mailbox.
17
+ * Returns the filename of the created message file.
18
+ */
19
+ export function writeMessage(brainPath, threadId, content) {
20
+ ensureMailboxDirs(brainPath, threadId);
21
+ const timestamp = process.hrtime.bigint().toString();
22
+ const filename = `${timestamp}.txt`;
23
+ const filepath = join(getMailboxPath(brainPath, threadId), NEW_DIR, filename);
24
+ writeFileSync(filepath, content, 'utf-8');
25
+ logger.info(`[Mailbox] Wrote message: ${filename} to ${getMailboxPath(brainPath, threadId)}/new/ (${content.length} chars, threadId=${threadId})`);
26
+ return filename;
27
+ }
28
+ /**
29
+ * Pick the oldest message from processing/ (crash recovery) or new/.
30
+ * If found in new/, moves it to processing/.
31
+ * Returns { filename, content } or null if no messages.
32
+ */
33
+ export function pickNextMessage(brainPath, threadId) {
34
+ ensureMailboxDirs(brainPath, threadId);
35
+ const mailbox = getMailboxPath(brainPath, threadId);
36
+ const processingDir = join(mailbox, PROCESSING_DIR);
37
+ const newDir = join(mailbox, NEW_DIR);
38
+ // First check processing/ (crash recovery)
39
+ const processingFiles = readdirSync(processingDir).filter(f => f.endsWith('.txt')).sort();
40
+ if (processingFiles.length > 0) {
41
+ const filename = processingFiles[0];
42
+ const content = readFileSync(join(processingDir, filename), 'utf-8');
43
+ logger.info(`[Mailbox] Resuming processing message: ${filename}`);
44
+ return { filename, content };
45
+ }
46
+ // Then check new/
47
+ const newFiles = readdirSync(newDir).filter(f => f.endsWith('.txt')).sort();
48
+ if (newFiles.length > 0) {
49
+ const filename = newFiles[0];
50
+ const srcPath = join(newDir, filename);
51
+ const dstPath = join(processingDir, filename);
52
+ const content = readFileSync(srcPath, 'utf-8');
53
+ renameSync(srcPath, dstPath);
54
+ logger.info(`[Mailbox] Picked message: ${filename} (moved to processing)`);
55
+ return { filename, content };
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Delete a message file from processing/ (marks it as done).
61
+ */
62
+ export function markProcessed(brainPath, threadId, filename) {
63
+ const filepath = join(getMailboxPath(brainPath, threadId), PROCESSING_DIR, filename);
64
+ try {
65
+ if (existsSync(filepath)) {
66
+ unlinkSync(filepath);
67
+ logger.info(`[Mailbox] Deleted processed message: ${filename}`);
68
+ }
69
+ }
70
+ catch (error) {
71
+ logger.warn(`[Mailbox] Failed to delete ${filename}: ${error}`);
72
+ }
73
+ }
74
+ /**
75
+ * Check if there are unprocessed messages in new/ or processing/.
76
+ */
77
+ export function hasUnprocessed(brainPath, threadId) {
78
+ const mailbox = getMailboxPath(brainPath, threadId);
79
+ const newDir = join(mailbox, NEW_DIR);
80
+ const processingDir = join(mailbox, PROCESSING_DIR);
81
+ try {
82
+ const newCount = existsSync(newDir) ? readdirSync(newDir).filter(f => f.endsWith('.txt')).length : 0;
83
+ const procCount = existsSync(processingDir) ? readdirSync(processingDir).filter(f => f.endsWith('.txt')).length : 0;
84
+ return (newCount + procCount) > 0;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ /**
91
+ * Get the mailbox new/ directory path for use in Claude's system prompt.
92
+ */
93
+ export function getMailboxNewPath(brainPath, threadId) {
94
+ return join(getMailboxPath(brainPath, threadId), NEW_DIR);
95
+ }
package/dist/poller.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { config } from './config.js';
2
2
  import { logger } from './logger.js';
3
- import { pollMessages, deleteMessage, extendVisibility, } from './sqs-poller.js';
3
+ import { pollMessages, deleteMessage, } from './sqs-poller.js';
4
4
  import { spawnClaudeCode, isAtCapacity, getActiveProcessCount, killAllActiveProcesses } 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';
@@ -9,79 +9,94 @@ 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 './inbox-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 runInboxLoop(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, inbox 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, threadId);
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
+ // Reconstruct queue message from stored inbox data
48
+ const storedData = JSON.parse(message.content);
49
+ const inboxMsg = {
50
+ ...queueMessage,
51
+ ...storedData,
52
+ };
53
+ const result = await spawnClaudeCode(inboxMsg, sessionLog, kbPath, session.session_id || undefined, isFirstMessage, session.last_message_ts, () => typing?.stop(), typing);
54
+ typing?.stop();
55
+ if (result.newSessionId && lockToken) {
56
+ await updateSessionId(threadId, lockToken, result.newSessionId);
57
+ }
58
+ if (result.metrics) {
59
+ sessionLog.info(`[Loop] Iteration #${iteration} complete — ` +
60
+ `cost: $${result.metrics.total_cost_usd.toFixed(4)}, ` +
61
+ `duration: ${result.metrics.duration_ms}ms`);
62
+ }
63
+ if (result.success) {
64
+ sessionLog.info(`[Loop] Claude completed iteration #${iteration}`);
65
+ if (queueMessage.teamvibe.brain?.brainId) {
66
+ await pushBrainChanges(kbPath, queueMessage.teamvibe.brain.brainId, queueMessage.teamvibe.workspaceId);
67
+ }
68
+ await postMaintenanceReports(kbPath, queueMessage.teamvibe.channelId);
69
+ }
70
+ else {
71
+ sessionLog.error(`[Loop] Claude failed iteration #${iteration}: ${result.error}`);
72
+ if (hasSlackContext) {
73
+ const errorMessage = result.error || `Process exited with code ${result.exitCode}`;
74
+ const isTimeout = errorMessage.includes('timed out');
75
+ await sendSlackError(queueMessage, errorMessage);
76
+ await addReaction(queueMessage, isTimeout ? 'hourglass' : 'x');
77
+ }
78
+ }
79
+ }
80
+ catch (error) {
81
+ typing?.stop();
82
+ sessionLog.error(`[Loop] Error in iteration #${iteration}: ${error instanceof Error ? error.message : error}`);
83
+ }
84
+ // Safety net: ensure the processing file is cleaned up
85
+ markProcessed(kbPath, threadId, message.filename);
86
+ if (!hasUnprocessed(kbPath, threadId)) {
87
+ break;
88
+ }
89
+ sessionLog.info(`[Loop] More messages found, continuing...`);
57
90
  }
58
- else {
59
- waitingCountByThread.set(threadId, count - 1);
91
+ if (lockToken) {
92
+ const lastMessageTs = queueMessage.response_context.slack?.message_ts;
93
+ await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
60
94
  }
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');
95
+ sessionLog.info(`[Loop] Complete. Iterations: ${iteration}`);
96
+ }
97
+ finally {
98
+ activeLoops.delete(threadId);
73
99
  }
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
100
  }
86
101
  async function processMessage(received) {
87
102
  const { queueMessage, receiptHandle, messageId } = received;
@@ -127,99 +142,29 @@ async function processMessage(received) {
127
142
  }
128
143
  // Register brain for heartbeat tracking
129
144
  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
- }
145
+ // Write raw message to inbox (spawnClaudeCode handles prompt formatting)
146
+ writeMessage(kbPath, threadId, JSON.stringify({
147
+ text: queueMessage.text,
148
+ sender: queueMessage.sender,
149
+ type: queueMessage.type,
150
+ source: queueMessage.source,
151
+ attachments: queueMessage.attachments,
152
+ approval: queueMessage.approval,
153
+ button: queueMessage.button,
154
+ modal: queueMessage.modal,
155
+ }));
156
+ sessionLog.info(`Wrote message to inbox`);
157
+ // Delete SQS message immediately — it's now persisted in the inbox
158
+ if (receiptHandle) {
159
+ await deleteMessage(receiptHandle);
160
+ sessionLog.info('SQS message deleted (persisted in inbox)');
162
161
  }
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
162
  processingMessages.add(messageId);
168
- const heartbeat = startHeartbeat(receiptHandle, sessionLog);
169
- // Start typing indicator
170
- const typing = hasSlackContext
171
- ? startTypingIndicator(queueMessage)
172
- : undefined;
173
163
  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');
164
+ await runInboxLoop(threadId, kbPath, queueMessage, sessionLog);
211
165
  }
212
166
  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
- }
167
+ sessionLog.error(`Error in inbox loop: ${error instanceof Error ? error.message : error}`);
223
168
  if (hasSlackContext) {
224
169
  try {
225
170
  const errMsg = error instanceof Error ? error.message : 'Unknown error';
@@ -239,9 +184,7 @@ async function processMessage(received) {
239
184
  throw error;
240
185
  }
241
186
  finally {
242
- clearInterval(heartbeat);
243
187
  processingMessages.delete(messageId);
244
- signalThreadCompletion(threadId);
245
188
  }
246
189
  }
247
190
  function deduplicateMessages(messages) {
@@ -279,7 +222,6 @@ async function pollLoop() {
279
222
  continue;
280
223
  }
281
224
  logger.info(`Received ${messages.length} message(s) from SQS`);
282
- logQueueState('After SQS poll');
283
225
  // Deduplicate: if multiple messages for the same thread, keep only the latest
284
226
  const { unique, duplicates } = deduplicateMessages(messages);
285
227
  for (const dup of duplicates) {
@@ -314,13 +256,6 @@ async function shutdown(signal) {
314
256
  stopRefresh();
315
257
  stopHeartbeatLoop();
316
258
  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
259
  // Kill all active Claude child processes
325
260
  await killAllActiveProcesses();
326
261
  // Wait for processingMessages to drain (with timeout)
@@ -357,21 +292,5 @@ export async function startPoller() {
357
292
  await ensureDirectories();
358
293
  await ensureBaseBrain();
359
294
  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
- }
295
+ await pollLoop();
377
296
  }
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.61",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {