@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "0.53.2",
3
+ "version": "0.54.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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
  };
@@ -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 Claude limits library for /limits command
48
- const { getClaudeUsageLimits, getCpuLoadInfo, getMemoryInfo, getDiskSpaceInfo, getGitHubRateLimits, formatUsageMessage } = await import('./claude-limits.lib.mjs');
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 the usage limits, system info, and GitHub rate limits in parallel
877
- const [result, cpuLoadResult, memoryResult, diskSpaceResult, githubLimitsResult] = await Promise.all([getClaudeUsageLimits(VERBOSE), getCpuLoadInfo(VERBOSE), getMemoryInfo(VERBOSE), getDiskSpaceInfo(VERBOSE), getGitHubRateLimits(VERBOSE)]);
873
+ // Get all limits using shared cache (3min for API, 2min for system)
874
+ const limits = await getAllCachedLimits(VERBOSE);
878
875
 
879
- if (!result.success) {
880
- // Edit the fetching message to show the error
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 and edit the fetching message with the results (pass all system info if available)
888
- const message = '📊 *Usage Limits*\n\n' + formatUsageMessage(result.usage, diskSpaceResult.success ? diskSpaceResult.diskSpace : null, githubLimitsResult.success ? githubLimitsResult.githubRateLimit : null, cpuLoadResult.success ? cpuLoadResult.cpuLoad : null, memoryResult.success ? memoryResult.memory : null);
889
- await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, {
890
- parse_mode: 'Markdown',
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: ${escapedUrl}\nOptions: ${optionsText}`;
1079
- if (solveOverrides.length > 0) {
1080
- infoBlock += `\n🔒 Locked options: ${solveOverrides.join(' ')}`;
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
+ };