@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.
- package/dist/claude-spawner.d.ts +3 -1
- package/dist/claude-spawner.js +53 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +2 -0
- package/dist/mailbox-manager.d.ts +22 -0
- package/dist/mailbox-manager.js +86 -0
- package/dist/poller.js +94 -184
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
package/dist/claude-spawner.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/claude-spawner.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
90
|
+
if (lockToken) {
|
|
91
|
+
const lastMessageTs = queueMessage.response_context.slack?.message_ts;
|
|
92
|
+
await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
|
|
60
93
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|