@link-assistant/hive-mind 0.53.2 → 0.54.0
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/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/{claude-limits.lib.mjs → limits.lib.mjs} +147 -0
- package/src/telegram-bot.mjs +33 -24
- package/src/telegram-solve-queue.lib.mjs +839 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 0.54.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4af584c: Add producer/consumer queue for /solve command in Telegram bot
|
|
8
|
+
|
|
9
|
+
This feature implements resource-aware throttling to prevent system overload when multiple /solve commands are submitted simultaneously.
|
|
10
|
+
|
|
11
|
+
**Queue Configuration (using usage ratios 0.0-1.0):**
|
|
12
|
+
- `RAM_THRESHOLD: 0.5` - Stop new commands if RAM usage > 50%
|
|
13
|
+
- `CPU_THRESHOLD: 0.5` - Stop new commands if CPU usage > 50%
|
|
14
|
+
- `DISK_THRESHOLD: 0.95` - One-at-a-time mode if disk usage > 95%
|
|
15
|
+
- `CLAUDE_SESSION_THRESHOLD: 0.9` - Stop if Claude 5-hour limit > 90%
|
|
16
|
+
- `CLAUDE_WEEKLY_THRESHOLD: 0.99` - One-at-a-time mode if weekly limit > 99%
|
|
17
|
+
- `GITHUB_API_THRESHOLD: 0.8` - Stop if GitHub API > 80% with parallel claude commands
|
|
18
|
+
- 1-minute minimum interval between command starts
|
|
19
|
+
- Running claude process detection
|
|
20
|
+
|
|
21
|
+
**Status Flow:**
|
|
22
|
+
- `Queued` - Initial status when command is added to queue
|
|
23
|
+
- `Waiting` - When start conditions are not met (with human-readable reason)
|
|
24
|
+
- `Starting` - When command is being started
|
|
25
|
+
- `Started` - Terminal status with session info (message tracking is released)
|
|
26
|
+
|
|
27
|
+
**Caching:**
|
|
28
|
+
- API calls (Claude, GitHub): 3-minute cache
|
|
29
|
+
- System metrics (RAM, CPU, disk): 2-minute cache
|
|
30
|
+
- Shared cache between /solve queue and /limits command
|
|
31
|
+
|
|
32
|
+
**Files Changed:**
|
|
33
|
+
- `limits.lib.mjs` - Merged from `claude-limits.lib.mjs` with added caching layer (replaces both `claude-limits.lib.mjs` and `telegram-limits.lib.mjs`)
|
|
34
|
+
- `telegram-solve-queue.lib.mjs` - Queue implementation with status tracking
|
|
35
|
+
|
|
36
|
+
**User Experience:**
|
|
37
|
+
- Messages are updated in-place as status changes
|
|
38
|
+
- Clear waiting reasons displayed (e.g., "Disk usage is 96% (threshold: 95%)")
|
|
39
|
+
- Queue status added to /limits command output
|
|
40
|
+
|
|
3
41
|
## 0.53.2
|
|
4
42
|
|
|
5
43
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -780,7 +780,143 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
|
|
|
780
780
|
return message;
|
|
781
781
|
}
|
|
782
782
|
|
|
783
|
+
// ============================================================================
|
|
784
|
+
// Caching Layer
|
|
785
|
+
// ============================================================================
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Cache TTL constants (in milliseconds)
|
|
789
|
+
*/
|
|
790
|
+
export const CACHE_TTL = {
|
|
791
|
+
API: 180000, // 3 minutes for API calls (Claude, GitHub)
|
|
792
|
+
SYSTEM: 120000, // 2 minutes for system metrics (RAM, CPU, disk)
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Generic cache class with configurable TTL
|
|
797
|
+
*/
|
|
798
|
+
class LimitCache {
|
|
799
|
+
constructor(defaultTtlMs = CACHE_TTL.API) {
|
|
800
|
+
this.defaultTtlMs = defaultTtlMs;
|
|
801
|
+
this.cache = new Map();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
get(key, ttlMs) {
|
|
805
|
+
const entry = this.cache.get(key);
|
|
806
|
+
if (!entry) return null;
|
|
807
|
+
const effectiveTtl = ttlMs ?? entry.ttlMs ?? this.defaultTtlMs;
|
|
808
|
+
if (Date.now() - entry.timestamp > effectiveTtl) {
|
|
809
|
+
this.cache.delete(key);
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
return entry.value;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
set(key, value, ttlMs) {
|
|
816
|
+
this.cache.set(key, { value, timestamp: Date.now(), ttlMs: ttlMs ?? this.defaultTtlMs });
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
clear() {
|
|
820
|
+
this.cache.clear();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
getStats() {
|
|
824
|
+
const now = Date.now();
|
|
825
|
+
let validEntries = 0;
|
|
826
|
+
let expiredEntries = 0;
|
|
827
|
+
for (const [, entry] of this.cache) {
|
|
828
|
+
const effectiveTtl = entry.ttlMs ?? this.defaultTtlMs;
|
|
829
|
+
if (now - entry.timestamp > effectiveTtl) {
|
|
830
|
+
expiredEntries++;
|
|
831
|
+
} else {
|
|
832
|
+
validEntries++;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return { validEntries, expiredEntries, totalEntries: this.cache.size };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
let globalCache = null;
|
|
840
|
+
|
|
841
|
+
export function getLimitCache() {
|
|
842
|
+
if (!globalCache) globalCache = new LimitCache();
|
|
843
|
+
return globalCache;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
export function resetLimitCache() {
|
|
847
|
+
if (globalCache) {
|
|
848
|
+
globalCache.clear();
|
|
849
|
+
globalCache = null;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export async function getCachedClaudeLimits(verbose = false) {
|
|
854
|
+
const cache = getLimitCache();
|
|
855
|
+
const cached = cache.get('claude', CACHE_TTL.API);
|
|
856
|
+
if (cached) {
|
|
857
|
+
if (verbose) console.log('[VERBOSE] /limits-cache: Using cached Claude limits');
|
|
858
|
+
return cached;
|
|
859
|
+
}
|
|
860
|
+
const result = await getClaudeUsageLimits(verbose);
|
|
861
|
+
if (result.success) cache.set('claude', result, CACHE_TTL.API);
|
|
862
|
+
return result;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
export async function getCachedGitHubLimits(verbose = false) {
|
|
866
|
+
const cache = getLimitCache();
|
|
867
|
+
const cached = cache.get('github', CACHE_TTL.API);
|
|
868
|
+
if (cached) {
|
|
869
|
+
if (verbose) console.log('[VERBOSE] /limits-cache: Using cached GitHub limits');
|
|
870
|
+
return cached;
|
|
871
|
+
}
|
|
872
|
+
const result = await getGitHubRateLimits(verbose);
|
|
873
|
+
if (result.success) cache.set('github', result, CACHE_TTL.API);
|
|
874
|
+
return result;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export async function getCachedMemoryInfo(verbose = false) {
|
|
878
|
+
const cache = getLimitCache();
|
|
879
|
+
const cached = cache.get('memory', CACHE_TTL.SYSTEM);
|
|
880
|
+
if (cached) {
|
|
881
|
+
if (verbose) console.log('[VERBOSE] /limits-cache: Using cached memory info');
|
|
882
|
+
return cached;
|
|
883
|
+
}
|
|
884
|
+
const result = await getMemoryInfo(verbose);
|
|
885
|
+
if (result.success) cache.set('memory', result, CACHE_TTL.SYSTEM);
|
|
886
|
+
return result;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
export async function getCachedCpuInfo(verbose = false) {
|
|
890
|
+
const cache = getLimitCache();
|
|
891
|
+
const cached = cache.get('cpu', CACHE_TTL.SYSTEM);
|
|
892
|
+
if (cached) {
|
|
893
|
+
if (verbose) console.log('[VERBOSE] /limits-cache: Using cached CPU info');
|
|
894
|
+
return cached;
|
|
895
|
+
}
|
|
896
|
+
const result = await getCpuLoadInfo(verbose);
|
|
897
|
+
if (result.success) cache.set('cpu', result, CACHE_TTL.SYSTEM);
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export async function getCachedDiskInfo(verbose = false) {
|
|
902
|
+
const cache = getLimitCache();
|
|
903
|
+
const cached = cache.get('disk', CACHE_TTL.SYSTEM);
|
|
904
|
+
if (cached) {
|
|
905
|
+
if (verbose) console.log('[VERBOSE] /limits-cache: Using cached disk info');
|
|
906
|
+
return cached;
|
|
907
|
+
}
|
|
908
|
+
const result = await getDiskSpaceInfo(verbose);
|
|
909
|
+
if (result.success) cache.set('disk', result, CACHE_TTL.SYSTEM);
|
|
910
|
+
return result;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export async function getAllCachedLimits(verbose = false) {
|
|
914
|
+
const [claude, github, memory, cpu, disk] = await Promise.all([getCachedClaudeLimits(verbose), getCachedGitHubLimits(verbose), getCachedMemoryInfo(verbose), getCachedCpuInfo(verbose), getCachedDiskInfo(verbose)]);
|
|
915
|
+
return { claude, github, memory, cpu, disk };
|
|
916
|
+
}
|
|
917
|
+
|
|
783
918
|
export default {
|
|
919
|
+
// Raw functions (no caching)
|
|
784
920
|
getClaudeUsageLimits,
|
|
785
921
|
getCpuLoadInfo,
|
|
786
922
|
getMemoryInfo,
|
|
@@ -789,4 +925,15 @@ export default {
|
|
|
789
925
|
getProgressBar,
|
|
790
926
|
calculateTimePassedPercentage,
|
|
791
927
|
formatUsageMessage,
|
|
928
|
+
// Cache management
|
|
929
|
+
CACHE_TTL,
|
|
930
|
+
getLimitCache,
|
|
931
|
+
resetLimitCache,
|
|
932
|
+
// Cached functions
|
|
933
|
+
getCachedClaudeLimits,
|
|
934
|
+
getCachedGitHubLimits,
|
|
935
|
+
getCachedMemoryInfo,
|
|
936
|
+
getCachedCpuInfo,
|
|
937
|
+
getCachedDiskInfo,
|
|
938
|
+
getAllCachedLimits,
|
|
792
939
|
};
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -44,14 +44,11 @@ const { parseGitHubUrl } = await import('./github.lib.mjs');
|
|
|
44
44
|
// Import model validation for early validation with helpful error messages
|
|
45
45
|
const { validateModelName } = await import('./model-validation.lib.mjs');
|
|
46
46
|
|
|
47
|
-
// Import
|
|
48
|
-
const {
|
|
49
|
-
|
|
50
|
-
// Import version info library for /version command
|
|
47
|
+
// Import libraries for /limits, /version, and markdown escaping
|
|
48
|
+
const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mjs');
|
|
51
49
|
const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
|
|
52
|
-
|
|
53
|
-
// Import Telegram markdown escaping utilities
|
|
54
50
|
const { escapeMarkdown, escapeMarkdownV2 } = await import('./telegram-markdown.lib.mjs');
|
|
51
|
+
const { getSolveQueue, getRunningClaudeProcesses, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
|
|
55
52
|
|
|
56
53
|
const config = yargs(hideBin(process.argv))
|
|
57
54
|
.usage('Usage: hive-telegram-bot [options]')
|
|
@@ -873,22 +870,26 @@ bot.command('limits', async ctx => {
|
|
|
873
870
|
reply_to_message_id: ctx.message.message_id,
|
|
874
871
|
});
|
|
875
872
|
|
|
876
|
-
// Get
|
|
877
|
-
const
|
|
873
|
+
// Get all limits using shared cache (3min for API, 2min for system)
|
|
874
|
+
const limits = await getAllCachedLimits(VERBOSE);
|
|
878
875
|
|
|
879
|
-
if (!
|
|
880
|
-
|
|
881
|
-
// Escape the error message for MarkdownV2, preserving inline code blocks
|
|
882
|
-
const escapedError = escapeMarkdownV2(result.error, { preserveCodeBlocks: true });
|
|
876
|
+
if (!limits.claude.success) {
|
|
877
|
+
const escapedError = escapeMarkdownV2(limits.claude.error, { preserveCodeBlocks: true });
|
|
883
878
|
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, `❌ ${escapedError}`, { parse_mode: 'MarkdownV2' });
|
|
884
879
|
return;
|
|
885
880
|
}
|
|
886
881
|
|
|
887
|
-
// Format
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
882
|
+
// Format the message with usage limits and queue status
|
|
883
|
+
let message = '📊 *Usage Limits*\n\n' + formatUsageMessage(limits.claude.usage, limits.disk.success ? limits.disk.diskSpace : null, limits.github.success ? limits.github.githubRateLimit : null, limits.cpu.success ? limits.cpu.cpuLoad : null, limits.memory.success ? limits.memory.memory : null);
|
|
884
|
+
const solveQueue = getSolveQueue({ verbose: VERBOSE });
|
|
885
|
+
const queueStats = solveQueue.getStats();
|
|
886
|
+
const claudeProcs = await getRunningClaudeProcesses(VERBOSE);
|
|
887
|
+
const codeBlockEnd = message.lastIndexOf('```');
|
|
888
|
+
if (codeBlockEnd !== -1) {
|
|
889
|
+
const queueStatus = queueStats.queued > 0 || queueStats.processing > 0 ? `Pending: ${queueStats.queued}, Processing: ${queueStats.processing}` : 'Empty (no pending commands)';
|
|
890
|
+
message = message.slice(0, codeBlockEnd) + `\nSolve Queue\n${queueStatus}\nClaude processes: ${claudeProcs.count}\n` + message.slice(codeBlockEnd);
|
|
891
|
+
}
|
|
892
|
+
await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown' });
|
|
892
893
|
});
|
|
893
894
|
bot.command('version', async ctx => {
|
|
894
895
|
VERBOSE && console.log('[VERBOSE] /version command received');
|
|
@@ -1073,15 +1074,23 @@ bot.command(/^solve$/i, async ctx => {
|
|
|
1073
1074
|
}
|
|
1074
1075
|
|
|
1075
1076
|
const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
|
|
1076
|
-
const escapedUrl = escapeMarkdown(args[0]);
|
|
1077
1077
|
const optionsText = args.slice(1).join(' ') || 'none';
|
|
1078
|
-
let infoBlock = `Requested by: ${requester}\nURL: ${
|
|
1079
|
-
if (solveOverrides.length > 0) {
|
|
1080
|
-
|
|
1078
|
+
let infoBlock = `Requested by: ${requester}\nURL: ${escapeMarkdown(args[0])}\nOptions: ${optionsText}`;
|
|
1079
|
+
if (solveOverrides.length > 0) infoBlock += `\n🔒 Locked options: ${solveOverrides.join(' ')}`;
|
|
1080
|
+
const solveQueue = getSolveQueue({ verbose: VERBOSE });
|
|
1081
|
+
const check = await solveQueue.canStartCommand();
|
|
1082
|
+
const queueStats = solveQueue.getStats();
|
|
1083
|
+
if (check.canStart && queueStats.queued === 0) {
|
|
1084
|
+
const startingMessage = await ctx.reply(`🚀 Starting solve command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
|
|
1085
|
+
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock);
|
|
1086
|
+
} else {
|
|
1087
|
+
const queueItem = solveQueue.enqueue({ url: args[0], args, ctx, requester, infoBlock, tool: solveTool });
|
|
1088
|
+
let queueMessage = `📋 Solve command queued (position #${queueStats.queued + 1})\n\n${infoBlock}`;
|
|
1089
|
+
if (check.reason) queueMessage += `\n\n⏳ Waiting: ${check.reason}`;
|
|
1090
|
+
const queuedMessage = await ctx.reply(queueMessage, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
|
|
1091
|
+
queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
|
|
1092
|
+
if (!solveQueue.executeCallback) solveQueue.executeCallback = createQueueExecuteCallback(executeStartScreen);
|
|
1081
1093
|
}
|
|
1082
|
-
|
|
1083
|
-
const startingMessage = await ctx.reply(`🚀 Starting solve command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
|
|
1084
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock);
|
|
1085
1094
|
});
|
|
1086
1095
|
|
|
1087
1096
|
bot.command(/^hive$/i, async ctx => {
|
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Telegram Solve Queue Library
|
|
4
|
+
*
|
|
5
|
+
* Producer/consumer queue for /solve commands in the Telegram bot.
|
|
6
|
+
* Implements resource-aware throttling to prevent system overload.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Resource checking (RAM, CPU, disk)
|
|
10
|
+
* - API limit checking (Claude, GitHub)
|
|
11
|
+
* - Minimum interval between command starts
|
|
12
|
+
* - Running process detection
|
|
13
|
+
* - Status tracking: Queued -> Waiting -> Starting -> Started
|
|
14
|
+
*
|
|
15
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1041
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { exec } from 'node:child_process';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
|
|
21
|
+
const execAsync = promisify(exec);
|
|
22
|
+
|
|
23
|
+
// Import centralized limits and caching
|
|
24
|
+
import { getCachedClaudeLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Configuration constants for queue throttling
|
|
28
|
+
* All thresholds use ratios (0.0 - 1.0) representing usage percentage
|
|
29
|
+
*/
|
|
30
|
+
export const QUEUE_CONFIG = {
|
|
31
|
+
// Resource thresholds (usage ratios: 0.0 - 1.0)
|
|
32
|
+
RAM_THRESHOLD: 0.5, // Stop if RAM usage > 50%
|
|
33
|
+
CPU_THRESHOLD: 0.5, // Stop if CPU usage > 50%
|
|
34
|
+
DISK_THRESHOLD: 0.95, // One-at-a-time if disk usage > 95%
|
|
35
|
+
|
|
36
|
+
// API limit thresholds (usage ratios: 0.0 - 1.0)
|
|
37
|
+
CLAUDE_SESSION_THRESHOLD: 0.9, // Stop if 5-hour limit > 90%
|
|
38
|
+
CLAUDE_WEEKLY_THRESHOLD: 0.99, // One-at-a-time if weekly limit > 99%
|
|
39
|
+
GITHUB_API_THRESHOLD: 0.8, // Stop if GitHub > 80% with parallel claude
|
|
40
|
+
|
|
41
|
+
// Timing
|
|
42
|
+
MIN_START_INTERVAL_MS: 60000, // 1 minute between starts
|
|
43
|
+
CONSUMER_POLL_INTERVAL_MS: 5000, // 5 seconds between queue checks
|
|
44
|
+
|
|
45
|
+
// Process detection
|
|
46
|
+
CLAUDE_PROCESS_NAMES: ['claude'], // Process names to detect
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Status enum for queue items
|
|
51
|
+
*/
|
|
52
|
+
export const QueueItemStatus = {
|
|
53
|
+
QUEUED: 'queued',
|
|
54
|
+
WAITING: 'waiting',
|
|
55
|
+
STARTING: 'starting',
|
|
56
|
+
STARTED: 'started',
|
|
57
|
+
FAILED: 'failed',
|
|
58
|
+
CANCELLED: 'cancelled',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Count running claude processes
|
|
63
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
64
|
+
* @returns {Promise<{count: number, processes: string[]}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function getRunningClaudeProcesses(verbose = false) {
|
|
67
|
+
try {
|
|
68
|
+
const { stdout } = await execAsync('pgrep -l -x claude 2>/dev/null || true');
|
|
69
|
+
const lines = stdout
|
|
70
|
+
.trim()
|
|
71
|
+
.split('\n')
|
|
72
|
+
.filter(line => line.trim());
|
|
73
|
+
|
|
74
|
+
const processes = lines
|
|
75
|
+
.map(line => {
|
|
76
|
+
const parts = line.trim().split(/\s+/);
|
|
77
|
+
return {
|
|
78
|
+
pid: parts[0],
|
|
79
|
+
name: parts.slice(1).join(' ') || 'claude',
|
|
80
|
+
};
|
|
81
|
+
})
|
|
82
|
+
.filter(p => p.pid);
|
|
83
|
+
|
|
84
|
+
if (verbose) {
|
|
85
|
+
console.log(`[VERBOSE] /solve-queue found ${processes.length} running claude processes`);
|
|
86
|
+
if (processes.length > 0) {
|
|
87
|
+
console.log(`[VERBOSE] /solve-queue processes: ${JSON.stringify(processes)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
count: processes.length,
|
|
93
|
+
processes: processes.map(p => `${p.pid}:${p.name}`),
|
|
94
|
+
};
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (verbose) {
|
|
97
|
+
console.error('[VERBOSE] /solve-queue error counting claude processes:', error.message);
|
|
98
|
+
}
|
|
99
|
+
return { count: 0, processes: [] };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Format a threshold as percentage for display
|
|
105
|
+
* @param {number} ratio - Ratio (0.0 - 1.0)
|
|
106
|
+
* @returns {string} Formatted percentage
|
|
107
|
+
*/
|
|
108
|
+
function formatThresholdPercent(ratio) {
|
|
109
|
+
return `${Math.round(ratio * 100)}%`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Generate human-readable waiting reason based on threshold violation
|
|
114
|
+
* @param {string} metric - The metric name (ram, cpu, disk, etc.)
|
|
115
|
+
* @param {number} currentValue - Current value (as percentage 0-100)
|
|
116
|
+
* @param {number} threshold - Threshold ratio (0.0 - 1.0)
|
|
117
|
+
* @returns {string} Human-readable reason
|
|
118
|
+
*/
|
|
119
|
+
function formatWaitingReason(metric, currentValue, threshold) {
|
|
120
|
+
const thresholdPercent = formatThresholdPercent(threshold);
|
|
121
|
+
const currentPercent = Math.round(currentValue);
|
|
122
|
+
|
|
123
|
+
switch (metric) {
|
|
124
|
+
case 'ram':
|
|
125
|
+
return `RAM usage is ${currentPercent}% (threshold: ${thresholdPercent})`;
|
|
126
|
+
case 'cpu':
|
|
127
|
+
return `CPU usage is ${currentPercent}% (threshold: ${thresholdPercent})`;
|
|
128
|
+
case 'disk':
|
|
129
|
+
return `Disk usage is ${currentPercent}% (threshold: ${thresholdPercent})`;
|
|
130
|
+
case 'claude_session':
|
|
131
|
+
return `Claude session limit is ${currentPercent}% (threshold: ${thresholdPercent})`;
|
|
132
|
+
case 'claude_weekly':
|
|
133
|
+
return `Claude weekly limit is ${currentPercent}% (threshold: ${thresholdPercent})`;
|
|
134
|
+
case 'github':
|
|
135
|
+
return `GitHub API usage is ${currentPercent}% (threshold: ${thresholdPercent})`;
|
|
136
|
+
case 'min_interval':
|
|
137
|
+
return `Minimum interval between commands not reached`;
|
|
138
|
+
case 'claude_running':
|
|
139
|
+
return `Claude process is already running`;
|
|
140
|
+
default:
|
|
141
|
+
return `${metric} threshold exceeded`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Queue item representing a /solve command request
|
|
147
|
+
*/
|
|
148
|
+
class SolveQueueItem {
|
|
149
|
+
constructor(options) {
|
|
150
|
+
this.id = `solve-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
151
|
+
this.url = options.url;
|
|
152
|
+
this.args = options.args;
|
|
153
|
+
this.ctx = options.ctx;
|
|
154
|
+
this.requester = options.requester;
|
|
155
|
+
this.infoBlock = options.infoBlock;
|
|
156
|
+
this.tool = options.tool || 'claude';
|
|
157
|
+
this.createdAt = new Date();
|
|
158
|
+
this.startedAt = null;
|
|
159
|
+
this.status = QueueItemStatus.QUEUED;
|
|
160
|
+
this.waitingReason = null;
|
|
161
|
+
this.error = null;
|
|
162
|
+
this.result = null;
|
|
163
|
+
this.sessionName = null;
|
|
164
|
+
// Message tracking - forget after STARTED
|
|
165
|
+
this.messageInfo = null; // { chatId, messageId }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Update status to waiting with reason
|
|
170
|
+
* @param {string} reason - Waiting reason
|
|
171
|
+
*/
|
|
172
|
+
setWaiting(reason) {
|
|
173
|
+
this.status = QueueItemStatus.WAITING;
|
|
174
|
+
this.waitingReason = reason;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Update status to starting
|
|
179
|
+
*/
|
|
180
|
+
setStarting() {
|
|
181
|
+
this.status = QueueItemStatus.STARTING;
|
|
182
|
+
this.startedAt = new Date();
|
|
183
|
+
this.waitingReason = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Update status to started and clear message tracking
|
|
188
|
+
* @param {string} sessionName - Session name for debugging
|
|
189
|
+
*/
|
|
190
|
+
setStarted(sessionName) {
|
|
191
|
+
this.status = QueueItemStatus.STARTED;
|
|
192
|
+
this.sessionName = sessionName;
|
|
193
|
+
// Terminal status - forget message tracking
|
|
194
|
+
this.messageInfo = null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Mark item as failed
|
|
199
|
+
* @param {Error|string} error - Error that occurred
|
|
200
|
+
*/
|
|
201
|
+
setFailed(error) {
|
|
202
|
+
this.status = QueueItemStatus.FAILED;
|
|
203
|
+
this.error = error instanceof Error ? error.message : error;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Mark item as cancelled
|
|
208
|
+
*/
|
|
209
|
+
setCancelled() {
|
|
210
|
+
this.status = QueueItemStatus.CANCELLED;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get wait time in queue (ms)
|
|
215
|
+
*/
|
|
216
|
+
getWaitTime() {
|
|
217
|
+
const endTime = this.startedAt || new Date();
|
|
218
|
+
return endTime - this.createdAt;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Format for display
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
toString() {
|
|
226
|
+
return `[${this.id}] ${this.url} (${this.status})`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Solve Queue - Producer/Consumer queue for /solve commands
|
|
232
|
+
*/
|
|
233
|
+
export class SolveQueue {
|
|
234
|
+
constructor(options = {}) {
|
|
235
|
+
this.verbose = options.verbose || false;
|
|
236
|
+
this.executeCallback = options.executeCallback || null;
|
|
237
|
+
this.messageUpdateCallback = options.messageUpdateCallback || null;
|
|
238
|
+
|
|
239
|
+
// Queue state
|
|
240
|
+
this.queue = [];
|
|
241
|
+
this.processing = new Map();
|
|
242
|
+
this.completed = [];
|
|
243
|
+
this.failed = [];
|
|
244
|
+
this.isRunning = true;
|
|
245
|
+
|
|
246
|
+
// Timing
|
|
247
|
+
this.lastStartTime = null;
|
|
248
|
+
|
|
249
|
+
// Consumer task reference
|
|
250
|
+
this.consumerTask = null;
|
|
251
|
+
|
|
252
|
+
// Statistics
|
|
253
|
+
this.stats = {
|
|
254
|
+
totalEnqueued: 0,
|
|
255
|
+
totalStarted: 0,
|
|
256
|
+
totalCompleted: 0,
|
|
257
|
+
totalFailed: 0,
|
|
258
|
+
totalCancelled: 0,
|
|
259
|
+
throttleReasons: {},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
this.log('SolveQueue initialized');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Log message if verbose mode is enabled
|
|
267
|
+
* @param {string} message
|
|
268
|
+
*/
|
|
269
|
+
log(message) {
|
|
270
|
+
if (this.verbose) {
|
|
271
|
+
console.log(`[VERBOSE] /solve-queue: ${message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Add a solve command to the queue
|
|
277
|
+
* @param {Object} options - Queue item options
|
|
278
|
+
* @returns {SolveQueueItem} The queued item
|
|
279
|
+
*/
|
|
280
|
+
enqueue(options) {
|
|
281
|
+
const item = new SolveQueueItem(options);
|
|
282
|
+
this.queue.push(item);
|
|
283
|
+
this.stats.totalEnqueued++;
|
|
284
|
+
|
|
285
|
+
this.log(`Enqueued: ${item.toString()}, queue length: ${this.queue.length}`);
|
|
286
|
+
|
|
287
|
+
// Start consumer if not already running
|
|
288
|
+
this.ensureConsumerRunning();
|
|
289
|
+
|
|
290
|
+
return item;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Cancel a queued item by ID
|
|
295
|
+
* @param {string} id - Item ID
|
|
296
|
+
* @returns {boolean} True if cancelled
|
|
297
|
+
*/
|
|
298
|
+
cancel(id) {
|
|
299
|
+
const queueIndex = this.queue.findIndex(item => item.id === id);
|
|
300
|
+
if (queueIndex !== -1) {
|
|
301
|
+
const item = this.queue.splice(queueIndex, 1)[0];
|
|
302
|
+
item.setCancelled();
|
|
303
|
+
this.stats.totalCancelled++;
|
|
304
|
+
this.log(`Cancelled queued item: ${item.toString()}`);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (this.processing.has(id)) {
|
|
309
|
+
this.log(`Cannot cancel processing item: ${id}`);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get queue statistics
|
|
318
|
+
* @returns {Object}
|
|
319
|
+
*/
|
|
320
|
+
getStats() {
|
|
321
|
+
return {
|
|
322
|
+
queued: this.queue.length,
|
|
323
|
+
processing: this.processing.size,
|
|
324
|
+
completed: this.completed.length,
|
|
325
|
+
failed: this.failed.length,
|
|
326
|
+
...this.stats,
|
|
327
|
+
cacheStats: getLimitCache().getStats(),
|
|
328
|
+
lastStartTime: this.lastStartTime,
|
|
329
|
+
isRunning: this.isRunning,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Get queue items summary for display
|
|
335
|
+
* @returns {Object}
|
|
336
|
+
*/
|
|
337
|
+
getQueueSummary() {
|
|
338
|
+
return {
|
|
339
|
+
pending: this.queue.map(item => ({
|
|
340
|
+
id: item.id,
|
|
341
|
+
url: item.url,
|
|
342
|
+
requester: item.requester,
|
|
343
|
+
waitTime: item.getWaitTime(),
|
|
344
|
+
createdAt: item.createdAt,
|
|
345
|
+
status: item.status,
|
|
346
|
+
waitingReason: item.waitingReason,
|
|
347
|
+
})),
|
|
348
|
+
processing: Array.from(this.processing.values()).map(item => ({
|
|
349
|
+
id: item.id,
|
|
350
|
+
url: item.url,
|
|
351
|
+
requester: item.requester,
|
|
352
|
+
startedAt: item.startedAt,
|
|
353
|
+
status: item.status,
|
|
354
|
+
})),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Check if a new command can start
|
|
360
|
+
* @returns {Promise<{canStart: boolean, reason?: string, reasons?: string[], oneAtATime?: boolean}>}
|
|
361
|
+
*/
|
|
362
|
+
async canStartCommand() {
|
|
363
|
+
const reasons = [];
|
|
364
|
+
let oneAtATime = false;
|
|
365
|
+
|
|
366
|
+
// Check minimum interval since last start
|
|
367
|
+
if (this.lastStartTime) {
|
|
368
|
+
const timeSinceLastStart = Date.now() - this.lastStartTime;
|
|
369
|
+
if (timeSinceLastStart < QUEUE_CONFIG.MIN_START_INTERVAL_MS) {
|
|
370
|
+
const waitSeconds = Math.ceil((QUEUE_CONFIG.MIN_START_INTERVAL_MS - timeSinceLastStart) / 1000);
|
|
371
|
+
reasons.push(formatWaitingReason('min_interval', 0, 0) + ` (${waitSeconds}s remaining)`);
|
|
372
|
+
this.recordThrottle('min_interval');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Check running claude processes
|
|
377
|
+
const claudeProcs = await getRunningClaudeProcesses(this.verbose);
|
|
378
|
+
if (claudeProcs.count > 0) {
|
|
379
|
+
reasons.push(formatWaitingReason('claude_running', claudeProcs.count, 0) + ` (${claudeProcs.count} processes)`);
|
|
380
|
+
this.recordThrottle('claude_running');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Check system resources
|
|
384
|
+
const resourceCheck = await this.checkSystemResources();
|
|
385
|
+
if (!resourceCheck.ok) {
|
|
386
|
+
reasons.push(...resourceCheck.reasons);
|
|
387
|
+
}
|
|
388
|
+
if (resourceCheck.oneAtATime) {
|
|
389
|
+
oneAtATime = true;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Check API limits
|
|
393
|
+
const limitCheck = await this.checkApiLimits(claudeProcs.count > 0);
|
|
394
|
+
if (!limitCheck.ok) {
|
|
395
|
+
reasons.push(...limitCheck.reasons);
|
|
396
|
+
}
|
|
397
|
+
if (limitCheck.oneAtATime) {
|
|
398
|
+
oneAtATime = true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const canStart = reasons.length === 0;
|
|
402
|
+
|
|
403
|
+
if (!canStart && this.verbose) {
|
|
404
|
+
this.log(`Cannot start: ${reasons.join(', ')}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
canStart,
|
|
409
|
+
reason: reasons.length > 0 ? reasons.join('\n') : undefined,
|
|
410
|
+
reasons,
|
|
411
|
+
oneAtATime,
|
|
412
|
+
claudeProcesses: claudeProcs.count,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Check system resources (RAM, CPU, disk) using cached values
|
|
418
|
+
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
|
|
419
|
+
*/
|
|
420
|
+
async checkSystemResources() {
|
|
421
|
+
const reasons = [];
|
|
422
|
+
let oneAtATime = false;
|
|
423
|
+
|
|
424
|
+
// Check RAM (using cached value)
|
|
425
|
+
const memResult = await getCachedMemoryInfo(this.verbose);
|
|
426
|
+
if (memResult.success) {
|
|
427
|
+
const usedRatio = memResult.memory.usedPercentage / 100;
|
|
428
|
+
if (usedRatio > QUEUE_CONFIG.RAM_THRESHOLD) {
|
|
429
|
+
reasons.push(formatWaitingReason('ram', memResult.memory.usedPercentage, QUEUE_CONFIG.RAM_THRESHOLD));
|
|
430
|
+
this.recordThrottle('ram_high');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Check CPU (using cached value)
|
|
435
|
+
const cpuResult = await getCachedCpuInfo(this.verbose);
|
|
436
|
+
if (cpuResult.success) {
|
|
437
|
+
const usedRatio = cpuResult.cpuLoad.usagePercentage / 100;
|
|
438
|
+
if (usedRatio > QUEUE_CONFIG.CPU_THRESHOLD) {
|
|
439
|
+
reasons.push(formatWaitingReason('cpu', cpuResult.cpuLoad.usagePercentage, QUEUE_CONFIG.CPU_THRESHOLD));
|
|
440
|
+
this.recordThrottle('cpu_high');
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Check disk space (using cached value)
|
|
445
|
+
const diskResult = await getCachedDiskInfo(this.verbose);
|
|
446
|
+
if (diskResult.success) {
|
|
447
|
+
// Calculate usage from free percentage
|
|
448
|
+
const usedPercent = 100 - diskResult.diskSpace.freePercentage;
|
|
449
|
+
const usedRatio = usedPercent / 100;
|
|
450
|
+
if (usedRatio > QUEUE_CONFIG.DISK_THRESHOLD) {
|
|
451
|
+
oneAtATime = true;
|
|
452
|
+
this.recordThrottle('disk_high');
|
|
453
|
+
if (this.processing.size > 0) {
|
|
454
|
+
reasons.push(formatWaitingReason('disk', usedPercent, QUEUE_CONFIG.DISK_THRESHOLD) + ' (waiting for current command)');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { ok: reasons.length === 0, reasons, oneAtATime };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Check API limits (Claude, GitHub) using cached values
|
|
464
|
+
* @param {boolean} hasRunningClaude - Whether claude processes are running
|
|
465
|
+
* @returns {Promise<{ok: boolean, reasons: string[], oneAtATime: boolean}>}
|
|
466
|
+
*/
|
|
467
|
+
async checkApiLimits(hasRunningClaude = false) {
|
|
468
|
+
const reasons = [];
|
|
469
|
+
let oneAtATime = false;
|
|
470
|
+
|
|
471
|
+
// Check Claude limits (using cached value)
|
|
472
|
+
const claudeResult = await getCachedClaudeLimits(this.verbose);
|
|
473
|
+
if (claudeResult.success) {
|
|
474
|
+
const sessionPercent = claudeResult.usage.currentSession.percentage;
|
|
475
|
+
const weeklyPercent = claudeResult.usage.allModels.percentage;
|
|
476
|
+
|
|
477
|
+
// Session limit (5-hour)
|
|
478
|
+
if (sessionPercent !== null) {
|
|
479
|
+
const sessionRatio = sessionPercent / 100;
|
|
480
|
+
if (sessionRatio >= 1.0) {
|
|
481
|
+
reasons.push('Claude session limit is 100% (waiting for reset)');
|
|
482
|
+
this.recordThrottle('claude_session_100');
|
|
483
|
+
} else if (sessionRatio >= QUEUE_CONFIG.CLAUDE_SESSION_THRESHOLD) {
|
|
484
|
+
reasons.push(formatWaitingReason('claude_session', sessionPercent, QUEUE_CONFIG.CLAUDE_SESSION_THRESHOLD));
|
|
485
|
+
this.recordThrottle('claude_session_high');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Weekly limit
|
|
490
|
+
if (weeklyPercent !== null) {
|
|
491
|
+
const weeklyRatio = weeklyPercent / 100;
|
|
492
|
+
if (weeklyRatio >= 1.0) {
|
|
493
|
+
oneAtATime = true;
|
|
494
|
+
this.recordThrottle('claude_weekly_100');
|
|
495
|
+
if (this.processing.size > 0) {
|
|
496
|
+
reasons.push('Claude weekly limit is 100% (waiting for current command)');
|
|
497
|
+
}
|
|
498
|
+
} else if (weeklyRatio >= QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD) {
|
|
499
|
+
oneAtATime = true;
|
|
500
|
+
this.recordThrottle('claude_weekly_high');
|
|
501
|
+
if (this.processing.size > 0) {
|
|
502
|
+
reasons.push(formatWaitingReason('claude_weekly', weeklyPercent, QUEUE_CONFIG.CLAUDE_WEEKLY_THRESHOLD) + ' (waiting for current command)');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Check GitHub limits (only relevant if claude processes running)
|
|
509
|
+
if (hasRunningClaude) {
|
|
510
|
+
const githubResult = await getCachedGitHubLimits(this.verbose);
|
|
511
|
+
if (githubResult.success) {
|
|
512
|
+
const usedPercent = githubResult.githubRateLimit.usedPercentage;
|
|
513
|
+
const usedRatio = usedPercent / 100;
|
|
514
|
+
if (usedRatio >= 1.0) {
|
|
515
|
+
reasons.push('GitHub API limit is 100% (waiting for reset)');
|
|
516
|
+
this.recordThrottle('github_100');
|
|
517
|
+
} else if (usedRatio >= QUEUE_CONFIG.GITHUB_API_THRESHOLD) {
|
|
518
|
+
reasons.push(formatWaitingReason('github', usedPercent, QUEUE_CONFIG.GITHUB_API_THRESHOLD));
|
|
519
|
+
this.recordThrottle('github_high');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { ok: reasons.length === 0, reasons, oneAtATime };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Record a throttle event for statistics
|
|
529
|
+
* @param {string} reason
|
|
530
|
+
*/
|
|
531
|
+
recordThrottle(reason) {
|
|
532
|
+
this.stats.throttleReasons[reason] = (this.stats.throttleReasons[reason] || 0) + 1;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Ensure consumer task is running
|
|
537
|
+
*/
|
|
538
|
+
ensureConsumerRunning() {
|
|
539
|
+
if (this.consumerTask) return;
|
|
540
|
+
|
|
541
|
+
this.consumerTask = this.runConsumer();
|
|
542
|
+
this.consumerTask.catch(error => {
|
|
543
|
+
console.error('[solve-queue] Consumer error:', error);
|
|
544
|
+
this.consumerTask = null;
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Update item message in Telegram
|
|
550
|
+
* @param {SolveQueueItem} item
|
|
551
|
+
* @param {string} text
|
|
552
|
+
*/
|
|
553
|
+
async updateItemMessage(item, text) {
|
|
554
|
+
if (!item.messageInfo || !item.ctx) return;
|
|
555
|
+
|
|
556
|
+
try {
|
|
557
|
+
const { chatId, messageId } = item.messageInfo;
|
|
558
|
+
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, text, { parse_mode: 'Markdown' });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
this.log(`Failed to update message: ${error.message}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Consumer loop - processes items from the queue
|
|
566
|
+
*/
|
|
567
|
+
async runConsumer() {
|
|
568
|
+
this.log('Consumer started');
|
|
569
|
+
|
|
570
|
+
while (this.isRunning) {
|
|
571
|
+
if (this.queue.length === 0) {
|
|
572
|
+
await this.sleep(QUEUE_CONFIG.CONSUMER_POLL_INTERVAL_MS);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const check = await this.canStartCommand();
|
|
577
|
+
|
|
578
|
+
if (!check.canStart) {
|
|
579
|
+
// Update all queued items to waiting status with reason
|
|
580
|
+
for (const item of this.queue) {
|
|
581
|
+
if (item.status === QueueItemStatus.QUEUED || item.status === QueueItemStatus.WAITING) {
|
|
582
|
+
const previousStatus = item.status;
|
|
583
|
+
const previousReason = item.waitingReason;
|
|
584
|
+
item.setWaiting(check.reason);
|
|
585
|
+
|
|
586
|
+
// Update message if status or reason changed
|
|
587
|
+
if (previousStatus !== item.status || previousReason !== item.waitingReason) {
|
|
588
|
+
const position = this.queue.indexOf(item) + 1;
|
|
589
|
+
await this.updateItemMessage(item, `⏳ Waiting (position #${position})\n\n${item.infoBlock}\n\n*Reason:*\n${check.reason}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
this.log(`Throttled: ${check.reason}`);
|
|
595
|
+
await this.sleep(QUEUE_CONFIG.CONSUMER_POLL_INTERVAL_MS);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Check one-at-a-time mode
|
|
600
|
+
if (check.oneAtATime && this.processing.size > 0) {
|
|
601
|
+
this.log('One-at-a-time mode: waiting for current command to finish');
|
|
602
|
+
await this.sleep(QUEUE_CONFIG.CONSUMER_POLL_INTERVAL_MS);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Get next item from queue
|
|
607
|
+
const item = this.queue.shift();
|
|
608
|
+
if (!item) continue;
|
|
609
|
+
|
|
610
|
+
// Check if this item uses claude tool and claude is running
|
|
611
|
+
if (item.tool === 'claude' && check.claudeProcesses > 0) {
|
|
612
|
+
this.queue.unshift(item);
|
|
613
|
+
this.log(`Claude tool item queued but claude running, waiting...`);
|
|
614
|
+
await this.sleep(QUEUE_CONFIG.CONSUMER_POLL_INTERVAL_MS);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Update status to Starting
|
|
619
|
+
item.setStarting();
|
|
620
|
+
this.processing.set(item.id, item);
|
|
621
|
+
this.lastStartTime = Date.now();
|
|
622
|
+
this.stats.totalStarted++;
|
|
623
|
+
|
|
624
|
+
// Update message to show Starting status
|
|
625
|
+
await this.updateItemMessage(item, `🚀 Starting solve command...\n\n${item.infoBlock}`);
|
|
626
|
+
|
|
627
|
+
this.log(`Starting: ${item.toString()}`);
|
|
628
|
+
|
|
629
|
+
// Execute in background
|
|
630
|
+
this.executeItem(item).catch(error => {
|
|
631
|
+
console.error(`[solve-queue] Execution error for ${item.id}:`, error);
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
this.log('Consumer stopped');
|
|
636
|
+
this.consumerTask = null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Execute a queue item
|
|
641
|
+
* @param {SolveQueueItem} item
|
|
642
|
+
*/
|
|
643
|
+
async executeItem(item) {
|
|
644
|
+
try {
|
|
645
|
+
if (this.executeCallback) {
|
|
646
|
+
const result = await this.executeCallback(item);
|
|
647
|
+
|
|
648
|
+
// Extract session name from result
|
|
649
|
+
let sessionName = 'unknown';
|
|
650
|
+
if (result && result.output) {
|
|
651
|
+
const sessionMatch = result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -r\s+(\S+)/);
|
|
652
|
+
if (sessionMatch) sessionName = sessionMatch[1];
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Update to Started status (terminal - forgets message tracking)
|
|
656
|
+
item.setStarted(sessionName);
|
|
657
|
+
this.stats.totalCompleted++;
|
|
658
|
+
|
|
659
|
+
// Final message update before forgetting
|
|
660
|
+
if (item.ctx && result) {
|
|
661
|
+
const { chatId, messageId } = item.messageInfo || {};
|
|
662
|
+
if (chatId && messageId) {
|
|
663
|
+
try {
|
|
664
|
+
if (result.warning) {
|
|
665
|
+
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, `⚠️ ${result.warning}`, { parse_mode: 'Markdown' });
|
|
666
|
+
} else if (result.success) {
|
|
667
|
+
const response = `✅ Solve command started successfully!\n\n📊 Session: \`${sessionName}\`\n\n${item.infoBlock}`;
|
|
668
|
+
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
|
|
669
|
+
} else {
|
|
670
|
+
const response = `❌ Error executing solve command:\n\n\`\`\`\n${result.error || result.output}\n\`\`\``;
|
|
671
|
+
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
|
|
672
|
+
}
|
|
673
|
+
} catch {
|
|
674
|
+
// Ignore message edit failures
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
item.setStarted('no-callback');
|
|
680
|
+
this.stats.totalCompleted++;
|
|
681
|
+
}
|
|
682
|
+
} catch (error) {
|
|
683
|
+
item.setFailed(error);
|
|
684
|
+
this.stats.totalFailed++;
|
|
685
|
+
console.error(`[solve-queue] Item failed: ${item.id}`, error);
|
|
686
|
+
|
|
687
|
+
// Try to update message with error
|
|
688
|
+
const { chatId, messageId } = item.messageInfo || {};
|
|
689
|
+
if (chatId && messageId && item.ctx) {
|
|
690
|
+
try {
|
|
691
|
+
await item.ctx.telegram.editMessageText(chatId, messageId, undefined, `❌ Error: ${error.message}`, { parse_mode: 'Markdown' });
|
|
692
|
+
} catch {
|
|
693
|
+
// Ignore
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} finally {
|
|
697
|
+
this.processing.delete(item.id);
|
|
698
|
+
|
|
699
|
+
if (item.status === QueueItemStatus.STARTED) {
|
|
700
|
+
this.completed.push(item);
|
|
701
|
+
} else if (item.status === QueueItemStatus.FAILED) {
|
|
702
|
+
this.failed.push(item);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
this.log(`Finished: ${item.toString()}`);
|
|
706
|
+
|
|
707
|
+
// Limit history size
|
|
708
|
+
while (this.completed.length > 100) this.completed.shift();
|
|
709
|
+
while (this.failed.length > 100) this.failed.shift();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Sleep for specified milliseconds
|
|
715
|
+
* @param {number} ms
|
|
716
|
+
* @returns {Promise<void>}
|
|
717
|
+
*/
|
|
718
|
+
sleep(ms) {
|
|
719
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Stop the queue
|
|
724
|
+
*/
|
|
725
|
+
stop() {
|
|
726
|
+
this.log('Stopping queue...');
|
|
727
|
+
this.isRunning = false;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Clear the limit cache
|
|
732
|
+
*/
|
|
733
|
+
clearCache() {
|
|
734
|
+
getLimitCache().clear();
|
|
735
|
+
this.log('Limit cache cleared');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Format queue status for display
|
|
740
|
+
* @returns {string}
|
|
741
|
+
*/
|
|
742
|
+
formatStatus() {
|
|
743
|
+
const stats = this.getStats();
|
|
744
|
+
if (stats.queued > 0 || stats.processing > 0) {
|
|
745
|
+
return `Solve Queue: ${stats.queued} pending, ${stats.processing} processing\n`;
|
|
746
|
+
}
|
|
747
|
+
return 'Solve Queue: empty\n';
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Format detailed queue status for Telegram message
|
|
752
|
+
* @returns {string}
|
|
753
|
+
*/
|
|
754
|
+
formatDetailedStatus() {
|
|
755
|
+
const stats = this.getStats();
|
|
756
|
+
const summary = this.getQueueSummary();
|
|
757
|
+
|
|
758
|
+
let message = '📋 *Solve Queue Status*\n\n';
|
|
759
|
+
message += `Pending: ${stats.queued}\n`;
|
|
760
|
+
message += `Processing: ${stats.processing}\n`;
|
|
761
|
+
message += `Completed: ${stats.completed}\n`;
|
|
762
|
+
message += `Failed: ${stats.failed}\n\n`;
|
|
763
|
+
|
|
764
|
+
if (summary.processing.length > 0) {
|
|
765
|
+
message += '*Currently Processing:*\n';
|
|
766
|
+
for (const item of summary.processing) {
|
|
767
|
+
message += `• ${item.url}\n`;
|
|
768
|
+
}
|
|
769
|
+
message += '\n';
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (summary.pending.length > 0) {
|
|
773
|
+
message += '*Waiting in Queue:*\n';
|
|
774
|
+
for (const item of summary.pending.slice(0, 5)) {
|
|
775
|
+
const waitSeconds = Math.floor(item.waitTime / 1000);
|
|
776
|
+
message += `• ${item.url} (${item.status}, ${waitSeconds}s)\n`;
|
|
777
|
+
if (item.waitingReason) {
|
|
778
|
+
message += ` └ ${item.waitingReason}\n`;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (summary.pending.length > 5) {
|
|
782
|
+
message += ` ... and ${summary.pending.length - 5} more\n`;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return message;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Global queue instance (singleton)
|
|
792
|
+
*/
|
|
793
|
+
let globalQueue = null;
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Get or create the global solve queue instance
|
|
797
|
+
* @param {Object} options - Queue options
|
|
798
|
+
* @returns {SolveQueue}
|
|
799
|
+
*/
|
|
800
|
+
export function getSolveQueue(options = {}) {
|
|
801
|
+
if (!globalQueue) {
|
|
802
|
+
globalQueue = new SolveQueue(options);
|
|
803
|
+
} else if (options.verbose !== undefined) {
|
|
804
|
+
globalQueue.verbose = options.verbose;
|
|
805
|
+
}
|
|
806
|
+
return globalQueue;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Reset the global queue (useful for testing)
|
|
811
|
+
*/
|
|
812
|
+
export function resetSolveQueue() {
|
|
813
|
+
if (globalQueue) {
|
|
814
|
+
globalQueue.stop();
|
|
815
|
+
globalQueue = null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Create an execute callback for the queue
|
|
821
|
+
* @param {Function} executeStartScreen - Function to execute start-screen command
|
|
822
|
+
* @returns {Function} Execute callback for queue items
|
|
823
|
+
*/
|
|
824
|
+
export function createQueueExecuteCallback(executeStartScreen) {
|
|
825
|
+
return async item => {
|
|
826
|
+
return await executeStartScreen('solve', item.args);
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export default {
|
|
831
|
+
SolveQueue,
|
|
832
|
+
SolveQueueItem,
|
|
833
|
+
getSolveQueue,
|
|
834
|
+
resetSolveQueue,
|
|
835
|
+
getRunningClaudeProcesses,
|
|
836
|
+
createQueueExecuteCallback,
|
|
837
|
+
QUEUE_CONFIG,
|
|
838
|
+
QueueItemStatus,
|
|
839
|
+
};
|