@link-assistant/hive-mind 1.21.2 → 1.21.4

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,32 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.21.4
4
+
5
+ ### Patch Changes
6
+
7
+ - ea19c72: Fix queue issues: rejection, display, and formatting
8
+ - Fix disk rejection not blocking queue placement when threshold exceeded
9
+ - Restore "used" label on progress bars when below threshold
10
+ - Show per-queue breakdown in /limits command
11
+ - Group queue items by tool and use human-readable time in /solve_queue
12
+
13
+ - aa42f3a: fix: improve merge queue error handling and debugging (Issue #1269)
14
+ - Always log errors (not just in verbose mode) for critical merge queue failures
15
+ - Always notify users via Telegram when merge queue fails unexpectedly
16
+ - Add timeout wrapper (60s) for onStatusUpdate callback to prevent infinite blocking
17
+ - Add error handling for CI check failures in waitForCI loop
18
+ - Add comprehensive case study documentation in docs/case-studies/issue-1269/
19
+
20
+ ## 1.21.3
21
+
22
+ ### Patch Changes
23
+
24
+ - 4426112: Fix error detection for `--tool agent` when JSON errors are pretty-printed (Issue #1258)
25
+ - Add fallback pattern matching for error events when NDJSON parsing fails
26
+ - Detect `"type": "error"` and `"type": "step_error"` patterns in raw output
27
+ - Detect critical error patterns like `AI_RetryError` and `UnhandledRejection`
28
+ - Extract error messages from output for better error reporting
29
+
3
30
  ## 1.21.2
4
31
 
5
32
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.21.2",
3
+ "version": "1.21.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "hive-telegram-bot": "./src/telegram-bot.mjs"
14
14
  },
15
15
  "scripts": {
16
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-solve-queue-command.mjs",
16
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs",
17
17
  "test:queue": "node tests/solve-queue.test.mjs",
18
18
  "test:limits-display": "node tests/limits-display.test.mjs",
19
19
  "test:usage-limit": "node tests/test-usage-limit.mjs",
package/src/agent.lib.mjs CHANGED
@@ -703,6 +703,51 @@ export const executeAgentCommand = async params => {
703
703
  outputError.match = streamingErrorMessage;
704
704
  }
705
705
 
706
+ // Issue #1258: Fallback pattern match for error detection
707
+ // When JSON parsing fails (e.g., multi-line pretty-printed JSON in logs),
708
+ // we need to detect error patterns in the raw output string
709
+ if (!outputError.detected && !streamingErrorDetected) {
710
+ // Check for error type patterns in raw output (handles pretty-printed JSON)
711
+ const errorTypePatterns = [
712
+ { pattern: '"type": "error"', type: 'AgentError' },
713
+ { pattern: '"type":"error"', type: 'AgentError' },
714
+ { pattern: '"type": "step_error"', type: 'AgentStepError' },
715
+ { pattern: '"type":"step_error"', type: 'AgentStepError' },
716
+ ];
717
+
718
+ for (const { pattern, type } of errorTypePatterns) {
719
+ if (fullOutput.includes(pattern)) {
720
+ outputError.detected = true;
721
+ outputError.type = type;
722
+ // Try to extract the error message from the output
723
+ const messageMatch = fullOutput.match(/"message":\s*"([^"]+)"/);
724
+ outputError.match = messageMatch ? messageMatch[1] : `Error event detected in output (fallback pattern match for ${pattern})`;
725
+ await log(`⚠️ Error event detected via fallback pattern match: ${outputError.match}`, { level: 'warning' });
726
+ break;
727
+ }
728
+ }
729
+
730
+ // Also check for known critical error patterns that indicate failure
731
+ if (!outputError.detected) {
732
+ const criticalErrorPatterns = [
733
+ { pattern: 'AI_RetryError:', extract: /AI_RetryError:\s*(.+?)(?:\n|$)/ },
734
+ { pattern: 'UnhandledRejection', extract: /"errorType":\s*"UnhandledRejection"/ },
735
+ { pattern: 'Failed after 3 attempts', extract: /Failed after \d+ attempts[^"]*/ },
736
+ ];
737
+
738
+ for (const { pattern, extract } of criticalErrorPatterns) {
739
+ if (fullOutput.includes(pattern)) {
740
+ outputError.detected = true;
741
+ outputError.type = 'CriticalError';
742
+ const match = fullOutput.match(extract);
743
+ outputError.match = match ? match[0] : `Critical error pattern detected: ${pattern}`;
744
+ await log(`⚠️ Critical error pattern detected via fallback: ${outputError.match}`, { level: 'warning' });
745
+ break;
746
+ }
747
+ }
748
+ }
749
+ }
750
+
706
751
  if (exitCode !== 0 || outputError.detected) {
707
752
  // Build JSON error structure for consistent error reporting
708
753
  const errorInfo = {
@@ -485,15 +485,39 @@ export async function mergePullRequest(owner, repo, prNumber, options = {}, verb
485
485
  * @returns {Promise<{success: boolean, status: string, error: string|null}>}
486
486
  */
487
487
  export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
488
- const { timeout = 30 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null } = options;
488
+ const {
489
+ timeout = 30 * 60 * 1000,
490
+ pollInterval = 30 * 1000,
491
+ onStatusUpdate = null,
492
+ // Issue #1269: Add timeout for callback to prevent infinite blocking
493
+ callbackTimeout = 60 * 1000, // 1 minute max for callback
494
+ } = options;
489
495
 
490
496
  const startTime = Date.now();
491
497
 
492
498
  while (Date.now() - startTime < timeout) {
493
- const ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
499
+ let ciStatus;
500
+ try {
501
+ ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
502
+ } catch (error) {
503
+ // Issue #1269: Log and continue on CI check errors instead of crashing
504
+ console.error(`[ERROR] /merge: Error checking CI status for PR #${prNumber}: ${error.message}`);
505
+ verbose && console.error(`[VERBOSE] /merge: CI check error details:`, error);
506
+ // Wait and retry
507
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
508
+ continue;
509
+ }
494
510
 
495
511
  if (onStatusUpdate) {
496
- await onStatusUpdate(ciStatus);
512
+ // Issue #1269: Wrap callback with timeout to prevent infinite blocking
513
+ try {
514
+ await Promise.race([onStatusUpdate(ciStatus), new Promise((_, reject) => setTimeout(() => reject(new Error(`Callback timeout after ${callbackTimeout}ms`)), callbackTimeout))]);
515
+ } catch (callbackError) {
516
+ // Issue #1269: Log callback errors but continue processing
517
+ console.error(`[ERROR] /merge: Status update callback failed for PR #${prNumber}: ${callbackError.message}`);
518
+ verbose && console.error(`[VERBOSE] /merge: Callback error details:`, callbackError);
519
+ // Continue processing even if callback fails - don't let UI issues block merging
520
+ }
497
521
  }
498
522
 
499
523
  if (ciStatus.status === 'success') {
@@ -714,11 +714,13 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
714
714
  if (cpuLoad) {
715
715
  message += 'CPU\n';
716
716
  const usedBar = getProgressBar(cpuLoad.usagePercentage, DISPLAY_THRESHOLDS.CPU);
717
- const warning = cpuLoad.usagePercentage >= DISPLAY_THRESHOLDS.CPU ? ' ⚠️' : '';
718
- message += `${usedBar} ${cpuLoad.usagePercentage}%${warning}\n`;
717
+ // Show 'used' label when below threshold, warning emoji when at/above threshold
718
+ // See: https://github.com/link-assistant/hive-mind/issues/1267
719
+ const suffix = cpuLoad.usagePercentage >= DISPLAY_THRESHOLDS.CPU ? ' ⚠️' : ' used';
720
+ message += `${usedBar} ${cpuLoad.usagePercentage}%${suffix}\n`;
719
721
  // Show cores used based on 5m load average (e.g., "0.04/6 CPU cores used" or "3/6 CPU cores used")
720
722
  // Use parseFloat to strip unnecessary trailing zeros (3.00 -> 3, 0.10 -> 0.1, 0.04 -> 0.04)
721
- message += `${parseFloat(cpuLoad.loadAvg5.toFixed(2))}/${cpuLoad.cpuCount} CPU cores used\n\n`;
723
+ message += `${parseFloat(cpuLoad.loadAvg5.toFixed(2))}/${cpuLoad.cpuCount} CPU cores\n\n`;
722
724
  }
723
725
 
724
726
  // Memory section (if provided)
@@ -726,8 +728,8 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
726
728
  if (memory) {
727
729
  message += 'RAM\n';
728
730
  const usedBar = getProgressBar(memory.usedPercentage, DISPLAY_THRESHOLDS.RAM);
729
- const warning = memory.usedPercentage >= DISPLAY_THRESHOLDS.RAM ? ' ⚠️' : '';
730
- message += `${usedBar} ${memory.usedPercentage}%${warning}\n`;
731
+ const suffix = memory.usedPercentage >= DISPLAY_THRESHOLDS.RAM ? ' ⚠️' : ' used';
732
+ message += `${usedBar} ${memory.usedPercentage}%${suffix}\n`;
731
733
  message += `${formatBytesRange(memory.usedBytes, memory.totalBytes)}\n\n`;
732
734
  }
733
735
 
@@ -737,8 +739,8 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
737
739
  message += 'Disk space\n';
738
740
  // Show used percentage with progress bar and threshold marker
739
741
  const usedBar = getProgressBar(diskSpace.usedPercentage, DISPLAY_THRESHOLDS.DISK);
740
- const warning = diskSpace.usedPercentage >= DISPLAY_THRESHOLDS.DISK ? ' ⚠️' : '';
741
- message += `${usedBar} ${diskSpace.usedPercentage}%${warning}\n`;
742
+ const suffix = diskSpace.usedPercentage >= DISPLAY_THRESHOLDS.DISK ? ' ⚠️' : ' used';
743
+ message += `${usedBar} ${diskSpace.usedPercentage}%${suffix}\n`;
742
744
  message += `${formatBytesRange(diskSpace.usedBytes, diskSpace.totalBytes)}\n\n`;
743
745
  }
744
746
 
@@ -748,9 +750,9 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
748
750
  message += 'GitHub API\n';
749
751
  // Show used percentage with progress bar and threshold marker
750
752
  const usedBar = getProgressBar(githubRateLimit.usedPercentage, DISPLAY_THRESHOLDS.GITHUB_API);
751
- const warning = githubRateLimit.usedPercentage >= DISPLAY_THRESHOLDS.GITHUB_API ? ' ⚠️' : '';
752
- message += `${usedBar} ${githubRateLimit.usedPercentage}%${warning}\n`;
753
- message += `${githubRateLimit.used}/${githubRateLimit.limit} requests used\n`;
753
+ const suffix = githubRateLimit.usedPercentage >= DISPLAY_THRESHOLDS.GITHUB_API ? ' ⚠️' : ' used';
754
+ message += `${usedBar} ${githubRateLimit.usedPercentage}%${suffix}\n`;
755
+ message += `${githubRateLimit.used}/${githubRateLimit.limit} requests\n`;
754
756
  if (githubRateLimit.relativeReset) {
755
757
  message += `Resets in ${githubRateLimit.relativeReset} (${githubRateLimit.resetTime})\n`;
756
758
  } else if (githubRateLimit.resetTime) {
@@ -775,8 +777,8 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
775
777
  // See: https://github.com/link-assistant/hive-mind/issues/1133
776
778
  const pct = Math.floor(usage.currentSession.percentage);
777
779
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CLAUDE_5_HOUR_SESSION);
778
- const warning = pct >= DISPLAY_THRESHOLDS.CLAUDE_5_HOUR_SESSION ? ' ⚠️' : '';
779
- message += `${bar} ${pct}%${warning}\n`;
780
+ const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_5_HOUR_SESSION ? ' ⚠️' : ' used';
781
+ message += `${bar} ${pct}%${suffix}\n`;
780
782
 
781
783
  if (usage.currentSession.resetTime) {
782
784
  const relativeTime = formatRelativeTime(usage.currentSession.resetsAt);
@@ -807,8 +809,8 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
807
809
  // See: https://github.com/link-assistant/hive-mind/issues/1133
808
810
  const pct = Math.floor(usage.allModels.percentage);
809
811
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CLAUDE_WEEKLY);
810
- const warning = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : '';
811
- message += `${bar} ${pct}%${warning}\n`;
812
+ const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : ' used';
813
+ message += `${bar} ${pct}%${suffix}\n`;
812
814
 
813
815
  if (usage.allModels.resetTime) {
814
816
  const relativeTime = formatRelativeTime(usage.allModels.resetsAt);
@@ -839,8 +841,8 @@ export function formatUsageMessage(usage, diskSpace = null, githubRateLimit = nu
839
841
  // See: https://github.com/link-assistant/hive-mind/issues/1133
840
842
  const pct = Math.floor(usage.sonnetOnly.percentage);
841
843
  const bar = getProgressBar(pct, DISPLAY_THRESHOLDS.CLAUDE_WEEKLY);
842
- const warning = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : '';
843
- message += `${bar} ${pct}%${warning}\n`;
844
+ const suffix = pct >= DISPLAY_THRESHOLDS.CLAUDE_WEEKLY ? ' ⚠️' : ' used';
845
+ message += `${bar} ${pct}%${suffix}\n`;
844
846
 
845
847
  if (usage.sonnetOnly.resetTime) {
846
848
  const relativeTime = formatRelativeTime(usage.sonnetOnly.resetsAt);
@@ -42,7 +42,7 @@ const { validateModelName } = await import('./model-validation.lib.mjs');
42
42
  const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mjs');
43
43
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
44
44
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
45
- const { getSolveQueue, getRunningClaudeProcesses, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
45
+ const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
46
46
  // Import extracted message filter functions for testability (issue #1207)
47
47
  const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText } = await import('./telegram-message-filters.lib.mjs');
48
48
 
@@ -784,16 +784,14 @@ bot.command('limits', async ctx => {
784
784
  // Format the message with usage limits and queue status
785
785
  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);
786
786
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
787
- const queueStats = solveQueue.getStats();
788
- const claudeProcs = await getRunningClaudeProcesses(VERBOSE);
789
- // Calculate total processing: queue-internal + external claude processes
790
- // This provides a uniform view of all processing happening
791
- // See: https://github.com/link-assistant/hive-mind/issues/1133
792
- const totalProcessing = queueStats.processing + claudeProcs.count;
787
+ // Insert per-queue status into the code block
788
+ // Shows each queue (claude, agent) with pending/processing counts
789
+ // Processing counts are actual running system processes (via pgrep)
790
+ // See: https://github.com/link-assistant/hive-mind/issues/1267
793
791
  const codeBlockEnd = message.lastIndexOf('```');
794
792
  if (codeBlockEnd !== -1) {
795
- const queueStatus = queueStats.queued > 0 || totalProcessing > 0 ? `Pending: ${queueStats.queued}, Processing: ${totalProcessing}` : 'Empty (no pending commands)';
796
- message = message.slice(0, codeBlockEnd) + `\nSolve Queue\n${queueStatus}\nClaude processes: ${claudeProcs.count}\n` + message.slice(codeBlockEnd);
793
+ const queueStatus = await solveQueue.formatStatus();
794
+ message = message.slice(0, codeBlockEnd) + `\n${queueStatus}` + message.slice(codeBlockEnd);
797
795
  }
798
796
  await ctx.telegram.editMessageText(fetchingMessage.chat.id, fetchingMessage.message_id, undefined, message, { parse_mode: 'Markdown' });
799
797
  });
@@ -850,7 +848,6 @@ const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, {
850
848
  isChatAuthorized,
851
849
  addBreadcrumb,
852
850
  getSolveQueue,
853
- getRunningClaudeProcesses,
854
851
  });
855
852
 
856
853
  // Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
@@ -1043,6 +1040,16 @@ async function handleSolveCommand(ctx) {
1043
1040
 
1044
1041
  const check = await solveQueue.canStartCommand({ tool: solveTool }); // Skip Claude limits for agent (#1159)
1045
1042
  const queueStats = solveQueue.getStats();
1043
+
1044
+ // Handle rejection: when a threshold strategy is 'reject', the command should fail immediately
1045
+ // without being placed in the queue. This ensures users get clear feedback about why
1046
+ // their command cannot be processed (e.g., disk full, server maintenance pending).
1047
+ // See: https://github.com/link-assistant/hive-mind/issues/1267
1048
+ if (check.rejected) {
1049
+ await ctx.reply(`❌ Solve command rejected.\n\n${infoBlock}\n\n🚫 Reason: ${check.rejectReason}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1050
+ return;
1051
+ }
1052
+
1046
1053
  if (check.canStart && queueStats.queued === 0) {
1047
1054
  const startingMessage = await ctx.reply(`🚀 Starting solve command...\n\n${infoBlock}`, { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
1048
1055
  await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock);
@@ -246,21 +246,31 @@ export function registerMergeCommand(bot, options) {
246
246
  await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, message, {
247
247
  parse_mode: 'MarkdownV2',
248
248
  });
249
+ VERBOSE && console.log(`[VERBOSE] /merge: Merge queue completed successfully for ${repoKey}`);
249
250
  } catch (err) {
250
- VERBOSE && console.log(`[VERBOSE] /merge: Error sending final message: ${err.message}`);
251
+ // Issue #1269: Always log completion failures (critical for debugging)
252
+ console.error(`[ERROR] /merge: Error sending final message for ${repoKey}: ${err.message}`);
251
253
  }
252
254
  activeMergeOperations.delete(repoKey);
253
255
  },
254
256
  onError: async error => {
255
- VERBOSE && console.error(`[VERBOSE] /merge error for ${repoKey}:`, error);
257
+ // Issue #1269: Always log errors (not just in verbose mode)
258
+ console.error(`[ERROR] /merge: Queue error for ${repoKey}:`, error.message);
259
+ VERBOSE && console.error(`[VERBOSE] /merge: Full error:`, error);
256
260
  try {
257
261
  const userMessage = formatUserError(error, VERBOSE);
258
262
  const finalReport = processor.formatFinalMessage();
259
- await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `❌ *Merge queue failed*\n\n${escapeMarkdownV2(userMessage)}\n\n${finalReport}`, {
263
+ // Issue #1269: Show error in the reply message with immediate feedback
264
+ // Keep a button so users know the error was displayed and can dismiss
265
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `❌ *Merge queue failed*\n\n⚠️ *Error:* ${escapeMarkdownV2(userMessage)}\n\n${finalReport}`, {
260
266
  parse_mode: 'MarkdownV2',
267
+ reply_markup: {
268
+ inline_keyboard: [[{ text: '❌ Failed - Click to dismiss', callback_data: `merge_dismiss_${repoKey}` }]],
269
+ },
261
270
  });
262
271
  } catch (err) {
263
- VERBOSE && console.log(`[VERBOSE] /merge: Error sending error message: ${err.message}`);
272
+ // Issue #1269: Always log notification failures
273
+ console.error(`[ERROR] /merge: Error sending error message for ${repoKey}: ${err.message}`);
264
274
  }
265
275
  activeMergeOperations.delete(repoKey);
266
276
  },
@@ -301,10 +311,36 @@ export function registerMergeCommand(bot, options) {
301
311
  });
302
312
 
303
313
  // Run the merge queue (this runs asynchronously)
304
- processor.run().catch(error => {
305
- VERBOSE && console.error(`[VERBOSE] /merge: Unhandled error in run(): ${error.message}`);
306
- activeMergeOperations.delete(repoKey);
307
- });
314
+ // Issue #1269: Improved error handling - always log errors and notify users
315
+ processor
316
+ .run()
317
+ .then(() => {
318
+ VERBOSE && console.log(`[VERBOSE] /merge: Merge queue completed for ${repoKey}`);
319
+ })
320
+ .catch(async error => {
321
+ // Always log errors (not just in verbose mode) - critical for debugging stuck queues
322
+ console.error(`[ERROR] /merge: Unhandled error in run() for ${repoKey}:`, error.message);
323
+ if (error.stack) {
324
+ console.error(`[ERROR] /merge: Stack trace: ${error.stack.split('\n').slice(0, 5).join('\n')}`);
325
+ }
326
+
327
+ // Always notify user about failure (Issue #1269)
328
+ // Show error in the reply message with immediate feedback
329
+ try {
330
+ const userMessage = formatUserError(error, VERBOSE);
331
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `❌ *Merge queue failed unexpectedly*\n\n⚠️ *Error:* ${escapeMarkdownV2(userMessage)}\n\n_The queue processing has stopped\\. Please try again or check server logs\\._`, {
332
+ parse_mode: 'MarkdownV2',
333
+ reply_markup: {
334
+ inline_keyboard: [[{ text: '❌ Failed - Click to dismiss', callback_data: `merge_dismiss_${repoKey}` }]],
335
+ },
336
+ });
337
+ } catch (notifyError) {
338
+ // Log notification failure but don't throw
339
+ console.error(`[ERROR] /merge: Failed to notify user about error: ${notifyError.message}`);
340
+ }
341
+
342
+ activeMergeOperations.delete(repoKey);
343
+ });
308
344
  } catch (error) {
309
345
  VERBOSE && console.error('[VERBOSE] /merge error:', error);
310
346
 
@@ -334,6 +370,21 @@ export function registerMergeCommand(bot, options) {
334
370
 
335
371
  VERBOSE && console.log(`[VERBOSE] /merge: Cancelled operation for ${repoKey}`);
336
372
  });
373
+
374
+ // Handle dismiss button callback (Issue #1269: for error acknowledgement)
375
+ bot.action(/^merge_dismiss_(.+)$/, async ctx => {
376
+ const repoKey = ctx.match[1];
377
+ VERBOSE && console.log(`[VERBOSE] /merge dismiss callback received for ${repoKey}`);
378
+
379
+ // Remove the inline keyboard button after user acknowledges the error
380
+ try {
381
+ await ctx.editMessageReplyMarkup({ inline_keyboard: [] });
382
+ await ctx.answerCbQuery('Error acknowledged.');
383
+ } catch {
384
+ // Ignore errors (message might have been edited already)
385
+ await ctx.answerCbQuery('Error acknowledged.');
386
+ }
387
+ });
337
388
  }
338
389
 
339
390
  /**
@@ -360,6 +360,11 @@ export class MergeQueueProcessor {
360
360
  item.error = error.message;
361
361
  item.completedAt = new Date();
362
362
  this.stats.failed++;
363
+ // Issue #1269: Always log errors (not just in verbose mode) for debugging
364
+ console.error(`[ERROR] /merge-queue: Error processing PR #${item.pr.number}: ${error.message}`);
365
+ if (error.stack) {
366
+ console.error(`[ERROR] /merge-queue: Stack trace: ${error.stack.split('\n').slice(0, 5).join('\n')}`);
367
+ }
363
368
  this.log(`Error processing PR #${item.pr.number}: ${error.message}`);
364
369
  }
365
370
  }
@@ -450,6 +455,19 @@ export class MergeQueueProcessor {
450
455
  message += `${statusEmoji} ${update.current}\n\n`;
451
456
  }
452
457
 
458
+ // Show errors/failures inline so user gets immediate feedback (Issue #1269)
459
+ const failedItems = update.items.filter(item => item.status === MergeItemStatus.FAILED && item.error);
460
+ if (failedItems.length > 0) {
461
+ message += `⚠️ *Errors:*\n`;
462
+ for (const item of failedItems.slice(0, 3)) {
463
+ message += ` \\#${item.prNumber}: ${this.escapeMarkdown(item.error.substring(0, 60))}${item.error.length > 60 ? '...' : ''}\n`;
464
+ }
465
+ if (failedItems.length > 3) {
466
+ message += ` _...and ${failedItems.length - 3} more errors_\n`;
467
+ }
468
+ message += '\n';
469
+ }
470
+
453
471
  // PRs list with emojis
454
472
  message += `*Queue:*\n`;
455
473
  for (const item of update.items.slice(0, 10)) {
@@ -24,11 +24,10 @@
24
24
  * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
25
25
  * @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
26
26
  * @param {Function} options.getSolveQueue - Function to get the solve queue instance
27
- * @param {Function} options.getRunningClaudeProcesses - Function to get running claude processes
28
27
  * @returns {{ handleSolveQueueCommand: Function }} The command handler for use in text fallback
29
28
  */
30
29
  export function registerSolveQueueCommand(bot, options) {
31
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb, getSolveQueue, getRunningClaudeProcesses } = options;
30
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb, getSolveQueue } = options;
32
31
 
33
32
  async function handleSolveQueueCommand(ctx) {
34
33
  VERBOSE && console.log('[VERBOSE] /solve_queue command received');
@@ -72,13 +71,12 @@ export function registerSolveQueueCommand(bot, options) {
72
71
  VERBOSE && console.log('[VERBOSE] /solve_queue passed all checks, generating status...');
73
72
 
74
73
  const solveQueue = getSolveQueue({ verbose: VERBOSE });
75
- const claudeProcs = await getRunningClaudeProcesses(VERBOSE);
76
74
 
77
75
  // Use the queue's built-in detailed status formatter
78
- let message = solveQueue.formatDetailedStatus();
79
-
80
- // Add running Claude processes info
81
- message += `\n🖥️ Running Claude processes: ${claudeProcs.count}`;
76
+ // Shows per-queue breakdown with first 5 items per queue and human-readable times
77
+ // Processing counts are actual running system processes (via pgrep)
78
+ // See: https://github.com/link-assistant/hive-mind/issues/1267
79
+ const message = await solveQueue.formatDetailedStatus();
82
80
 
83
81
  await ctx.reply(message, {
84
82
  parse_mode: 'Markdown',
@@ -43,13 +43,14 @@ export const QueueItemStatus = {
43
43
  };
44
44
 
45
45
  /**
46
- * Count running claude processes
46
+ * Count running processes by name
47
+ * @param {string} processName - Process name to search for (e.g., 'claude', 'agent')
47
48
  * @param {boolean} verbose - Whether to log verbose output
48
49
  * @returns {Promise<{count: number, processes: string[]}>}
49
50
  */
50
- export async function getRunningClaudeProcesses(verbose = false) {
51
+ export async function getRunningProcesses(processName, verbose = false) {
51
52
  try {
52
- const { stdout } = await execAsync('pgrep -l -x claude 2>/dev/null || true');
53
+ const { stdout } = await execAsync(`pgrep -l -x ${processName} 2>/dev/null || true`);
53
54
  const lines = stdout
54
55
  .trim()
55
56
  .split('\n')
@@ -60,13 +61,13 @@ export async function getRunningClaudeProcesses(verbose = false) {
60
61
  const parts = line.trim().split(/\s+/);
61
62
  return {
62
63
  pid: parts[0],
63
- name: parts.slice(1).join(' ') || 'claude',
64
+ name: parts.slice(1).join(' ') || processName,
64
65
  };
65
66
  })
66
67
  .filter(p => p.pid);
67
68
 
68
69
  if (verbose) {
69
- console.log(`[VERBOSE] /solve_queue found ${processes.length} running claude processes`);
70
+ console.log(`[VERBOSE] /solve_queue found ${processes.length} running ${processName} processes`);
70
71
  if (processes.length > 0) {
71
72
  console.log(`[VERBOSE] /solve_queue processes: ${JSON.stringify(processes)}`);
72
73
  }
@@ -78,12 +79,30 @@ export async function getRunningClaudeProcesses(verbose = false) {
78
79
  };
79
80
  } catch (error) {
80
81
  if (verbose) {
81
- console.error('[VERBOSE] /solve_queue error counting claude processes:', error.message);
82
+ console.error(`[VERBOSE] /solve_queue error counting ${processName} processes:`, error.message);
82
83
  }
83
84
  return { count: 0, processes: [] };
84
85
  }
85
86
  }
86
87
 
88
+ /**
89
+ * Count running claude processes
90
+ * @param {boolean} verbose - Whether to log verbose output
91
+ * @returns {Promise<{count: number, processes: string[]}>}
92
+ */
93
+ export async function getRunningClaudeProcesses(verbose = false) {
94
+ return getRunningProcesses('claude', verbose);
95
+ }
96
+
97
+ /**
98
+ * Count running agent processes
99
+ * @param {boolean} verbose - Whether to log verbose output
100
+ * @returns {Promise<{count: number, processes: string[]}>}
101
+ */
102
+ export async function getRunningAgentProcesses(verbose = false) {
103
+ return getRunningProcesses('agent', verbose);
104
+ }
105
+
87
106
  /**
88
107
  * Format a threshold as percentage for display
89
108
  * @param {number} ratio - Ratio (0.0 - 1.0)
@@ -93,6 +112,33 @@ function formatThresholdPercent(ratio) {
93
112
  return `${Math.round(ratio * 100)}%`;
94
113
  }
95
114
 
115
+ /**
116
+ * Format milliseconds into human-readable duration
117
+ * Shows days, hours, minutes, and seconds as appropriate.
118
+ * Examples: "5h 43m 23s", "2m 15s", "45s", "1d 3h 12m 5s"
119
+ *
120
+ * @param {number} ms - Duration in milliseconds
121
+ * @returns {string} Human-readable duration
122
+ * @see https://github.com/link-assistant/hive-mind/issues/1267
123
+ */
124
+ export function formatDuration(ms) {
125
+ if (ms < 0) ms = 0;
126
+
127
+ const totalSeconds = Math.floor(ms / 1000);
128
+ const days = Math.floor(totalSeconds / 86400);
129
+ const hours = Math.floor((totalSeconds % 86400) / 3600);
130
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
131
+ const seconds = totalSeconds % 60;
132
+
133
+ const parts = [];
134
+ if (days > 0) parts.push(`${days}d`);
135
+ if (hours > 0) parts.push(`${hours}h`);
136
+ if (minutes > 0) parts.push(`${minutes}m`);
137
+ if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
138
+
139
+ return parts.join(' ');
140
+ }
141
+
96
142
  /**
97
143
  * Generate human-readable waiting reason based on threshold violation
98
144
  * @param {string} metric - The metric name (ram, cpu, disk, etc.)
@@ -1035,7 +1081,11 @@ export class SolveQueue {
1035
1081
  const itemCheck = await this.canStartCommand({ tool: item.tool });
1036
1082
  const previousStatus = item.status;
1037
1083
  const previousReason = item.waitingReason;
1038
- item.setWaiting(itemCheck.reason || 'Waiting in queue');
1084
+ // Use rejectReason when threshold strategy is 'reject', otherwise use reason
1085
+ // This ensures disk-full and other rejection reasons are shown properly
1086
+ // See: https://github.com/link-assistant/hive-mind/issues/1267
1087
+ const waitReason = itemCheck.rejectReason || itemCheck.reason || 'Waiting in queue';
1088
+ item.setWaiting(waitReason);
1039
1089
 
1040
1090
  // Update message if status/reason changed or it's time for periodic update
1041
1091
  const shouldUpdate = previousStatus !== item.status || previousReason !== item.waitingReason || this.shouldUpdateMessage(item);
@@ -1158,74 +1208,113 @@ export class SolveQueue {
1158
1208
  }
1159
1209
 
1160
1210
  /**
1161
- * Format queue status for display
1162
- * Shows per-tool queue counts.
1163
- * @returns {string}
1211
+ * Format queue status for display in /limits command
1212
+ * Shows per-tool queue breakdown with processing counts.
1213
+ *
1214
+ * Processing count = actual running system processes (via pgrep), not items in queue processing state.
1215
+ * This is because items transition quickly through the processing state, but the actual
1216
+ * work happens in the spawned system process (claude, agent, etc.).
1217
+ *
1218
+ * Output format:
1219
+ * ```
1220
+ * Queues
1221
+ * claude (pending: 6, processing: 0)
1222
+ * agent (pending: 2, processing: 0)
1223
+ * ```
1224
+ *
1225
+ * @returns {Promise<string>}
1164
1226
  * @see https://github.com/link-assistant/hive-mind/issues/1159
1227
+ * @see https://github.com/link-assistant/hive-mind/issues/1267
1165
1228
  */
1166
- formatStatus() {
1167
- const stats = this.getStats();
1168
- if (stats.queued > 0 || stats.processing > 0) {
1169
- // Show per-tool breakdown if there are items
1170
- const toolBreakdown = Object.entries(stats.queuedByTool)
1171
- .filter(entry => entry[1] > 0)
1172
- .map(([tool, count]) => `${tool}: ${count}`)
1173
- .join(', ');
1174
- const queueInfo = toolBreakdown ? ` (${toolBreakdown})` : '';
1175
- return `Solve Queue: ${stats.queued} pending${queueInfo}, ${stats.processing} processing\n`;
1229
+ async formatStatus() {
1230
+ // Get actual process counts for each tool queue
1231
+ // The "processing" count is the number of running system processes, not queue internal state
1232
+ // This ensures users see accurate counts of what's actually running
1233
+ const claudeProcs = await getRunningClaudeProcesses(this.verbose);
1234
+ const agentProcs = await getRunningAgentProcesses(this.verbose);
1235
+
1236
+ const processCounts = {
1237
+ claude: claudeProcs.count,
1238
+ agent: agentProcs.count,
1239
+ };
1240
+
1241
+ // Always show per-tool breakdown for all known queues
1242
+ let message = 'Queues\n';
1243
+ for (const [tool, toolQueue] of Object.entries(this.queues)) {
1244
+ const pending = toolQueue.length;
1245
+ const processing = processCounts[tool] || 0;
1246
+ message += `${tool} (pending: ${pending}, processing: ${processing})\n`;
1176
1247
  }
1177
- return 'Solve Queue: empty\n';
1248
+
1249
+ return message;
1178
1250
  }
1179
1251
 
1180
1252
  /**
1181
1253
  * Format detailed queue status for Telegram message
1182
- * Shows per-tool queue breakdown.
1183
- * @returns {string}
1254
+ * Groups output by tool queue, shows first 5 items per queue, and uses human-readable time.
1255
+ *
1256
+ * Processing count = actual running system processes (via pgrep), not items in queue processing state.
1257
+ * This is because items transition quickly through the processing state, but the actual
1258
+ * work happens in the spawned system process (claude, agent, etc.).
1259
+ *
1260
+ * Output format:
1261
+ * ```
1262
+ * 📋 Solve Queue Status
1263
+ *
1264
+ * claude (pending: 6, processing: 0)
1265
+ * • url1 (waiting, 5h 43m 23s)
1266
+ * └ RAM usage is 70% (threshold: 65%)
1267
+ * • url2 (queued, 2m 15s)
1268
+ *
1269
+ * agent (pending: 2, processing: 0)
1270
+ * • url3 (waiting, 1h 2m 5s)
1271
+ * ```
1272
+ *
1273
+ * @returns {Promise<string>}
1184
1274
  * @see https://github.com/link-assistant/hive-mind/issues/1159
1275
+ * @see https://github.com/link-assistant/hive-mind/issues/1267
1185
1276
  */
1186
- formatDetailedStatus() {
1277
+ async formatDetailedStatus() {
1187
1278
  const stats = this.getStats();
1188
- const summary = this.getQueueSummary();
1189
1279
 
1190
- let message = '📋 *Solve Queue Status*\n\n';
1191
- message += `Pending: ${stats.queued}`;
1192
-
1193
- // Add per-tool breakdown
1194
- const toolBreakdown = Object.entries(stats.queuedByTool)
1195
- .filter(entry => entry[1] > 0)
1196
- .map(([tool, count]) => `${tool}: ${count}`)
1197
- .join(', ');
1198
- if (toolBreakdown) {
1199
- message += ` (${toolBreakdown})`;
1200
- }
1201
- message += '\n';
1280
+ // Get actual process counts for each tool queue
1281
+ // The "processing" count is the number of running system processes, not queue internal state
1282
+ // This ensures users see accurate counts of what's actually running
1283
+ const claudeProcs = await getRunningClaudeProcesses(this.verbose);
1284
+ const agentProcs = await getRunningAgentProcesses(this.verbose);
1202
1285
 
1203
- message += `Processing: ${stats.processing}\n`;
1204
- message += `Completed: ${stats.completed}\n`;
1205
- message += `Failed: ${stats.failed}\n\n`;
1286
+ const processCounts = {
1287
+ claude: claudeProcs.count,
1288
+ agent: agentProcs.count,
1289
+ };
1206
1290
 
1207
- if (summary.processing.length > 0) {
1208
- message += '*Currently Processing:*\n';
1209
- for (const item of summary.processing) {
1210
- message += `• ${item.url} [${item.tool}]\n`;
1211
- }
1212
- message += '\n';
1213
- }
1291
+ let message = '📋 *Solve Queue Status*\n\n';
1214
1292
 
1215
- if (summary.pending.length > 0) {
1216
- message += '*Waiting in Queue:*\n';
1217
- for (const item of summary.pending.slice(0, 5)) {
1218
- const waitSeconds = Math.floor(item.waitTime / 1000);
1219
- message += `• ${item.url} [${item.tool}] (${item.status}, ${waitSeconds}s)\n`;
1293
+ // Show per-tool queue breakdown with items grouped by queue
1294
+ for (const [tool, toolQueue] of Object.entries(this.queues)) {
1295
+ const pending = toolQueue.length;
1296
+ const processing = processCounts[tool] || 0;
1297
+ message += `*${tool}* (pending: ${pending}, processing: ${processing})\n`;
1298
+
1299
+ // Show first 5 queued items for this tool
1300
+ const displayItems = toolQueue.slice(0, 5);
1301
+ for (const item of displayItems) {
1302
+ const waitTime = formatDuration(item.getWaitTime());
1303
+ message += ` • ${item.url} (${item.status}, ${waitTime})\n`;
1220
1304
  if (item.waitingReason) {
1221
- message += ` └ ${item.waitingReason}\n`;
1305
+ message += ` └ ${item.waitingReason}\n`;
1222
1306
  }
1223
1307
  }
1224
- if (summary.pending.length > 5) {
1225
- message += ` ... and ${summary.pending.length - 5} more\n`;
1308
+ if (toolQueue.length > 5) {
1309
+ message += ` ... and ${toolQueue.length - 5} more\n`;
1226
1310
  }
1311
+
1312
+ message += '\n';
1227
1313
  }
1228
1314
 
1315
+ // Summary stats
1316
+ message += `Completed: ${stats.completed}, Failed: ${stats.failed}\n`;
1317
+
1229
1318
  return message;
1230
1319
  }
1231
1320
  }
@@ -1275,8 +1364,11 @@ export default {
1275
1364
  SolveQueueItem,
1276
1365
  getSolveQueue,
1277
1366
  resetSolveQueue,
1367
+ getRunningProcesses,
1278
1368
  getRunningClaudeProcesses,
1369
+ getRunningAgentProcesses,
1279
1370
  createQueueExecuteCallback,
1371
+ formatDuration,
1280
1372
  QUEUE_CONFIG,
1281
1373
  QueueItemStatus,
1282
1374
  };