@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.
- package/dist/claude-spawner.d.ts +3 -1
- package/dist/claude-spawner.js +63 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +2 -0
- package/dist/inbox-manager.d.ts +22 -0
- package/dist/inbox-manager.js +89 -0
- package/dist/mailbox-manager.d.ts +26 -0
- package/dist/mailbox-manager.js +95 -0
- package/dist/poller.js +102 -183
- 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,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,
|
|
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
|
-
//
|
|
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 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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
91
|
+
if (lockToken) {
|
|
92
|
+
const lastMessageTs = queueMessage.response_context.slack?.message_ts;
|
|
93
|
+
await releaseSessionLock(threadId, lockToken, 'idle', lastMessageTs);
|
|
60
94
|
}
|
|
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');
|
|
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
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|