@link-assistant/hive-mind 1.73.7 → 1.73.9

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,73 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.73.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 0a5b615: fix(telegram): list currently-executing tasks in `/solve_queue` (`/queue`), not just count them (#1837)
8
+
9
+ After the original #1837 work added clickable lists, the detailed status still
10
+ showed only a `processing: N` **count** for in-flight work — the executing task
11
+ itself was never rendered as a clickable link, which is exactly the case the
12
+ issue cares most about ("search tasks that are stuck or yet executing").
13
+
14
+ Root cause: the processing **count** comes from the external snapshot
15
+ (`max(pgrep, tracked-isolation-session count)`), but the processing **list**
16
+ iterated the queue's own in-memory `processing` Map. `executeItem()` deletes an
17
+ item from that Map the moment the work is dispatched to a detached
18
+ screen/isolation session, so while a task is actually executing the Map is empty
19
+ — count says `1`, list shows nothing.
20
+
21
+ The fix sources the executing items from the same place the count comes from. A
22
+ new `getRunningSessionItems()` in `session-monitor.lib.mjs` returns the
23
+ currently-running detached sessions (with their GitHub `url`, `tool`, `status`,
24
+ `startTime`), reusing the existing isolation `$ --status` / non-isolation
25
+ screen-liveness checks. New helpers `collectExecutingItems` and
26
+ `formatQueueProcessingItems` merge those sessions with the in-memory Map (deduped
27
+ by normalized GitHub URL, filtered by tool) and render them as the `▶️
28
+ [owner/repo#n](url) (status, duration)` lines, capped with `... and N more`.
29
+ `formatDetailedStatus()` now lists executing tasks from this merged source.
30
+
31
+ Adds `tests/test-issue-1837-executing-list.mjs` plus new `solve-queue.test.mjs`
32
+ cases, and documents the root cause and fix in `docs/case-studies/issue-1837`.
33
+
34
+ ## 1.73.8
35
+
36
+ ### Patch Changes
37
+
38
+ - 324ed89: fix(solve): surface the core tool error instead of bare `CLAUDE execution failed` (#1845)
39
+
40
+ When an AI tool run failed, both the terminal and the posted GitHub
41
+ `🚨 Solution Draft Failed` comment showed only the generic
42
+ `CLAUDE execution failed`, even though the underlying tool had reported a
43
+ specific cause (for example `API Error: Output blocked by content filtering
44
+ policy`). The real message was captured inside the tool runner but dropped at
45
+ the failure-return boundary, so no downstream consumer could display it.
46
+
47
+ Every AI tool runner now surfaces a structured `errorInfo` (with a `.message`)
48
+ on its failure returns (`claude`, `gemini`, `opencode`, `qwen`; `codex` and
49
+ `agent` already did). Two shared helpers in `lib.mjs` — `extractToolErrorCore`
50
+ (the core error string) and `formatToolExecutionFailure` (the full
51
+ `CLAUDE execution failed with API Error: Output blocked by content filtering
52
+ policy` message) — share one precedence so every surface stays consistent.
53
+ All failure sites now use them: `solve.mjs` (terminal exit, GitHub failure
54
+ comment, critical-error auto-commit reason), `solve.auto-merge.lib.mjs` and
55
+ `solve.watch.lib.mjs` (GitHub message + new terminal `Error details:` lines),
56
+ and `review.mjs`. The helpers collapse whitespace, cap the core error length,
57
+ and never fall back to the agent's success summary.
58
+
59
+ `isApiError` in `solve.restart-shared.lib.mjs` now classifies through the same
60
+ extractor, so a Claude `API Error:` reported via `errorInfo` (never `result`)
61
+ is detected and watch mode's `MAX_API_ERROR_RETRIES` backoff guard keeps
62
+ working instead of retrying forever.
63
+
64
+ The auto-commit-on-critical-error path (#1834) is confirmed to run on the
65
+ failure exit and is now labeled with the real failure cause; the same guarded
66
+ auto-commit is also added to `handleFailure()` so the `uncaughtException`,
67
+ `unhandledRejection`, and top-level-catch exits preserve uncommitted work too.
68
+ Adds unit, cross-tool, auto-commit, and `isApiError` tests plus a deep case
69
+ study in `docs/case-studies/issue-1845`.
70
+
3
71
  ## 1.73.7
4
72
 
5
73
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.73.7",
3
+ "version": "1.73.9",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -1191,6 +1191,8 @@ export const executeClaudeCommand = async params => {
1191
1191
  is503Error,
1192
1192
  anthropicTotalCostUSD,
1193
1193
  resultSummary,
1194
+ // Issue #1845: surface the actual error so callers can show it to users
1195
+ errorInfo: { message: lastMessage || 'API explicitly marked error as not retryable', exitCode },
1194
1196
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1195
1197
  };
1196
1198
  }
@@ -1235,6 +1237,8 @@ export const executeClaudeCommand = async params => {
1235
1237
  is503Error, // preserve for callers that check this
1236
1238
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1237
1239
  resultSummary, // Issue #1263: Include result summary
1240
+ // Issue #1845: surface the actual error so callers can show it to users
1241
+ errorInfo: { message: lastMessage || `Transient API error persisted after ${maxRetries} retries`, exitCode },
1238
1242
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1239
1243
  };
1240
1244
  }
@@ -1294,6 +1298,9 @@ export const executeClaudeCommand = async params => {
1294
1298
  errorDuringExecution,
1295
1299
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1296
1300
  resultSummary, // Issue #1263: Include result summary
1301
+ // Issue #1845: surface the core error (e.g. "API Error: Output blocked by content
1302
+ // filtering policy") so users see what actually went wrong, not just a generic message.
1303
+ errorInfo: { message: lastMessage || `Claude command failed with exit code ${exitCode}`, exitCode },
1297
1304
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1298
1305
  };
1299
1306
  }
@@ -1374,6 +1381,8 @@ export const executeClaudeCommand = async params => {
1374
1381
  toolUseCount,
1375
1382
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1376
1383
  resultSummary, // Issue #1263: Include result summary
1384
+ // Issue #1845: surface the actual exception message so callers can show it to users
1385
+ errorInfo: { message: error.message || error.toString() },
1377
1386
  queuedFeedback, // Issue #817: Bidirectional mode feedback
1378
1387
  };
1379
1388
  }
@@ -575,6 +575,8 @@ export const executeGeminiCommand = async params => {
575
575
  pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
576
576
  publicPricingEstimate: null,
577
577
  resultSummary: geminiJsonState.resultSummary || null,
578
+ // Issue #1845: surface the actual error so callers can show it to users
579
+ errorInfo: { message: errorText || `Gemini command failed with exit code ${exitCode}`, exitCode },
578
580
  };
579
581
  }
580
582
 
@@ -616,6 +618,8 @@ export const executeGeminiCommand = async params => {
616
618
  pricingInfo: null,
617
619
  publicPricingEstimate: null,
618
620
  resultSummary: null,
621
+ // Issue #1845: surface the actual exception message so callers can show it to users
622
+ errorInfo: { message: error.message || error.toString() },
619
623
  };
620
624
  }
621
625
  };
package/src/lib.mjs CHANGED
@@ -664,6 +664,67 @@ export const cleanErrorMessage = error => {
664
664
  return message;
665
665
  };
666
666
 
667
+ /**
668
+ * Extract the core/root error string from a tool runner result (Issue #1845).
669
+ *
670
+ * Applies a single precedence everywhere so every failure surface shows the
671
+ * same root cause: `errorInfo.message` → `errorInfo.errorMatch` → string
672
+ * `errorInfo` → `result`. Returns a collapsed single line, or null when no
673
+ * usable error string is available. Shared by `formatToolExecutionFailure`
674
+ * (GitHub comments / exit message) and the terminal "Error details:" lines in
675
+ * watch / auto-merge so they never diverge.
676
+ *
677
+ * @param {Object} options
678
+ * @param {Object} [options.toolResult] - Result object returned by the tool runner
679
+ * @returns {string|null} The core error string, or null when none is available
680
+ */
681
+ export const extractToolErrorCore = ({ toolResult } = {}) => {
682
+ // Prefer the structured error message surfaced by the tool runner. We do NOT
683
+ // fall back to resultSummary here, because that holds the agent's normal
684
+ // work summary on success and would be misleading when used as an error.
685
+ const errorInfo = toolResult?.errorInfo;
686
+ const rawCore = errorInfo?.message || errorInfo?.errorMatch || (typeof errorInfo === 'string' ? errorInfo : null) || toolResult?.result || null;
687
+
688
+ if (!rawCore || typeof rawCore !== 'string') return null;
689
+
690
+ // Collapse to a single clean line and strip noise.
691
+ const core = rawCore.replace(/\s+/g, ' ').trim();
692
+ return core || null;
693
+ };
694
+
695
+ /**
696
+ * Build a user-facing tool execution failure message that includes the core
697
+ * error reported by the underlying tool (Issue #1845).
698
+ *
699
+ * Previously users only saw the generic "<TOOL> execution failed" and had to
700
+ * dig through the full failure log to discover what actually went wrong (for
701
+ * example "API Error: Output blocked by content filtering policy"). When the
702
+ * tool runner surfaces a specific error this appends it so the failure is
703
+ * self-explanatory:
704
+ *
705
+ * "CLAUDE execution failed with API Error: Output blocked by content filtering policy"
706
+ *
707
+ * Falls back to the generic phrase when no specific error is available.
708
+ *
709
+ * @param {Object} options
710
+ * @param {string} [options.tool] - Tool name (e.g. 'claude'); defaults to 'claude'
711
+ * @param {Object} [options.toolResult] - Result object returned by the tool runner
712
+ * @param {number} [options.maxLength=300] - Max length of the appended core error
713
+ * @returns {string} The formatted failure message
714
+ */
715
+ export const formatToolExecutionFailure = ({ tool, toolResult, maxLength = 300 } = {}) => {
716
+ const base = `${(tool || 'claude').toUpperCase()} execution failed`;
717
+
718
+ let core = extractToolErrorCore({ toolResult });
719
+ if (!core) return base;
720
+
721
+ // Avoid duplicating the base phrase if the core error already contains it.
722
+ if (core.toLowerCase().includes('execution failed')) return base;
723
+
724
+ if (core.length > maxLength) core = `${core.slice(0, maxLength - 1)}…`;
725
+ return `${base} with ${core}`;
726
+ };
727
+
667
728
  /**
668
729
  * Format aligned console output
669
730
  * @param {string} icon - Icon to display
@@ -466,6 +466,8 @@ export const executeOpenCodeCommand = async params => {
466
466
  permissionPromptDetected: true,
467
467
  ...pricingResult,
468
468
  resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
469
+ // Issue #1845: surface the actual error so callers can show it to users
470
+ errorInfo: { message: 'OpenCode requested an interactive permission prompt (non-interactive run cannot continue)' },
469
471
  };
470
472
  }
471
473
 
@@ -528,6 +530,8 @@ export const executeOpenCodeCommand = async params => {
528
530
  limitResetTime,
529
531
  ...pricingResult,
530
532
  resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
533
+ // Issue #1845: surface the actual error so callers can show it to users
534
+ errorInfo: { message: lastMessage || allOutput || `OpenCode command failed with exit code ${exitCode}`, exitCode },
531
535
  };
532
536
  }
533
537
 
@@ -593,6 +597,8 @@ export const executeOpenCodeCommand = async params => {
593
597
  pricingInfo: null,
594
598
  publicPricingEstimate: null,
595
599
  resultSummary: null, // Issue #1263: No result summary available on error
600
+ // Issue #1845: surface the actual exception message so callers can show it to users
601
+ errorInfo: { message: error.message || error.toString() },
596
602
  };
597
603
  }
598
604
  };
package/src/qwen.lib.mjs CHANGED
@@ -632,6 +632,8 @@ export const executeQwenCommand = async params => {
632
632
  limitResetTime: null,
633
633
  ...usageResult,
634
634
  resultSummary,
635
+ // Issue #1845: surface the actual error so callers can show it to users
636
+ errorInfo: { message: combinedErrorText || errorMessage || `Qwen Code command failed${exitCode !== 0 ? ` with exit code ${exitCode}` : ''}`, exitCode },
635
637
  };
636
638
  }
637
639
 
@@ -665,6 +667,8 @@ export const executeQwenCommand = async params => {
665
667
  publicPricingEstimate: null,
666
668
  tokenUsage: null,
667
669
  resultSummary: null,
670
+ // Issue #1845: surface the actual exception message so callers can show it to users
671
+ errorInfo: { message: error.message || error.toString() },
668
672
  };
669
673
  }
670
674
  };
package/src/review.mjs CHANGED
@@ -43,7 +43,7 @@ const path = (await use('path')).default;
43
43
  const fs = (await use('fs')).promises;
44
44
 
45
45
  // Import shared functions from lib.mjs to follow DRY principle
46
- import { log, setLogFile, getLogFile, formatAligned } from './lib.mjs';
46
+ import { log, setLogFile, getLogFile, formatAligned, extractToolErrorCore } from './lib.mjs';
47
47
  import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
48
48
  import { reportError } from './sentry.lib.mjs';
49
49
  import * as memoryCheck from './memory-check.mjs';
@@ -398,7 +398,9 @@ Review this pull request thoroughly.`;
398
398
 
399
399
  // Handle command failure
400
400
  if (!commandSuccess) {
401
- await log('\n❌ Command execution failed. Check the log file for details.');
401
+ // Issue #1845: surface the core error (e.g. "API Error: ...") instead of just a generic message.
402
+ const reviewErrorCore = extractToolErrorCore({ toolResult: result });
403
+ await log(`\n❌ Command execution failed${reviewErrorCore ? ` with ${reviewErrorCore}` : '. Check the log file for details.'}`);
402
404
  await log(`📁 Log file: ${getLogFile()}`);
403
405
  process.exit(1);
404
406
  }
@@ -578,6 +578,78 @@ export async function getRunningTrackedIsolationSessions(verbose = false, option
578
578
  return { count: sessions.length, sessions, byTool };
579
579
  }
580
580
 
581
+ /**
582
+ * Return the currently-executing tracked sessions with the details needed to
583
+ * render them as a clickable list in `/solve_queue` (`/queue`): the issue/PR
584
+ * `url`, the `tool`, the start time, and (for isolation sessions) the backend
585
+ * status. Both isolation and non-isolation screen sessions are included so the
586
+ * list matches what is actually executing — the queue's own in-memory
587
+ * `processing` Map is empty once a task has been dispatched to a detached
588
+ * session, which is why executing tasks were previously not listed.
589
+ *
590
+ * Liveness is determined the same way as {@link monitorSessions}: isolation
591
+ * sessions via `$ --status`, non-isolation screen sessions via a timeout window
592
+ * plus a best-effort `screen -ls` check.
593
+ *
594
+ * @param {boolean} verbose - Whether to log verbose output
595
+ * @param {Object} [options] - Test/support options
596
+ * @param {Function} [options.statusProvider] - Optional `$ --status` provider
597
+ * @param {Function} [options.screenChecker] - Optional screen-existence checker
598
+ * @returns {Promise<Array<{sessionName: string, url: string|null, tool: string, status: string|null, startTime: (Date|string|number|null), isolationBackend: (string|null)}>>}
599
+ * @see https://github.com/link-assistant/hive-mind/issues/1837
600
+ */
601
+ export async function getRunningSessionItems(verbose = false, options = {}) {
602
+ const items = [];
603
+ const screenChecker = options.screenChecker || checkScreenSessionExists;
604
+
605
+ for (const [sessionName, sessionInfo] of activeSessions.entries()) {
606
+ let running = false;
607
+ let status = null;
608
+
609
+ if (sessionInfo.isolationBackend) {
610
+ const state = await getIsolationSessionState(sessionName, sessionInfo, {
611
+ verbose,
612
+ statusProvider: options.statusProvider,
613
+ });
614
+ running = state.running;
615
+ status = state.status || null;
616
+ if (!running) {
617
+ sessionInfo.lastKnownStatus = state.status || null;
618
+ sessionInfo.lastKnownExitCode = state.exitCode ?? null;
619
+ continue;
620
+ }
621
+ } else {
622
+ const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
623
+ const elapsed = Date.now() - startTime.getTime();
624
+ if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
625
+ if (verbose) {
626
+ console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s; excluded from running list`);
627
+ }
628
+ continue;
629
+ }
630
+ running = await screenChecker(sessionName);
631
+ if (!running) {
632
+ continue;
633
+ }
634
+ }
635
+
636
+ items.push({
637
+ sessionName,
638
+ url: sessionInfo.url || null,
639
+ tool: sessionInfo.tool || 'claude',
640
+ status,
641
+ startTime: sessionInfo.startTime || null,
642
+ isolationBackend: sessionInfo.isolationBackend || null,
643
+ });
644
+ }
645
+
646
+ if (verbose) {
647
+ console.log(`[VERBOSE] getRunningSessionItems found ${items.length} running session(s)`);
648
+ }
649
+
650
+ return items;
651
+ }
652
+
581
653
  /**
582
654
  * Get statistics about session tracking
583
655
  * @param {boolean} verbose - Whether to log verbose output
@@ -22,7 +22,7 @@ const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
22
22
  const $ = wrapDollarWithGhRetry(__rawDollar$);
23
23
  // Import shared library functions
24
24
  const lib = await import('./lib.mjs');
25
- const { log, cleanErrorMessage, formatAligned, getLogFile } = lib;
25
+ const { log, cleanErrorMessage, formatAligned, formatToolExecutionFailure, extractToolErrorCore, getLogFile } = lib;
26
26
 
27
27
  // Note: We don't use detectAndCountFeedback from solve.feedback.lib.mjs
28
28
  // because we have our own non-bot comment detection logic that's more
@@ -805,6 +805,8 @@ No further AI sessions will be started automatically for this run. Please review
805
805
  // Resume failed for a non-limit reason — stop the loop
806
806
  await log('');
807
807
  await log(formatAligned('❌', `${argv.tool.toUpperCase()} RESUME FAILED`, ''));
808
+ // Issue #1845: surface the core error in the terminal, not just in the GitHub log.
809
+ await log(formatAligned('', 'Error details:', extractToolErrorCore({ toolResult: resumeResult }) || 'Unknown error', 2));
808
810
  await log(formatAligned('', 'Action:', 'Stopping auto-restart — tool execution failed after limit reset', 2));
809
811
  // Issue #1439: Attach failure log before stopping, so user can see what happened
810
812
  const shouldAttachLogsOnResumeFail = argv.attachLogs || argv['attach-logs'];
@@ -822,7 +824,7 @@ No further AI sessions will be started automatically for this run. Please review
822
824
  log,
823
825
  sanitizeLogContent,
824
826
  verbose: argv.verbose,
825
- errorMessage: `${argv.tool.toUpperCase()} execution failed after limit reset`,
827
+ errorMessage: `${formatToolExecutionFailure({ tool: argv.tool, toolResult: resumeResult })} after limit reset`,
826
828
  sessionId: latestSessionId,
827
829
  tempDir,
828
830
  requestedModel: argv.originalModel || argv.model,
@@ -855,6 +857,8 @@ No further AI sessions will be started automatically for this run. Please review
855
857
  // Per reviewer feedback: non-limit failures should fail and stop attempts
856
858
  await log('');
857
859
  await log(formatAligned('❌', `${argv.tool.toUpperCase()} EXECUTION FAILED`, ''));
860
+ // Issue #1845: surface the core error in the terminal, not just in the GitHub log.
861
+ await log(formatAligned('', 'Error details:', extractToolErrorCore({ toolResult }) || 'Unknown error', 2));
858
862
  await log(formatAligned('', 'Action:', 'Stopping auto-restart — tool execution failed', 2));
859
863
  // Issue #1439: Attach failure log before stopping, so user can see what happened
860
864
  const shouldAttachLogsOnFail = argv.attachLogs || argv['attach-logs'];
@@ -872,7 +876,7 @@ No further AI sessions will be started automatically for this run. Please review
872
876
  log,
873
877
  sanitizeLogContent,
874
878
  verbose: argv.verbose,
875
- errorMessage: `${argv.tool.toUpperCase()} execution failed`,
879
+ errorMessage: formatToolExecutionFailure({ tool: argv.tool, toolResult }),
876
880
  sessionId: latestSessionId,
877
881
  tempDir,
878
882
  requestedModel: argv.originalModel || argv.model,
@@ -18,9 +18,30 @@ export const isErrorIssueAutoCreationDisabled = argv => !!(argv?.disableReportIs
18
18
  * Handles log attachment and PR closing on failure
19
19
  */
20
20
  export const handleFailure = async options => {
21
- const { error, errorType, shouldAttachLogs, argv, global, owner, repo, log, getLogFile, attachLogToGitHub, cleanErrorMessage, sanitizeLogContent, $ } = options;
21
+ const { error, errorType, shouldAttachLogs, argv, global, owner, repo, log, getLogFile, attachLogToGitHub, cleanErrorMessage, sanitizeLogContent, cleanupContext, $ } = options;
22
22
  const disableIssueCreation = isErrorIssueAutoCreationDisabled(argv);
23
23
 
24
+ // Issue #1845 / #1834: "On all failures we automatically commit uncommitted changes by default."
25
+ // Exceptions, unhandled rejections and main-execution errors exit here WITHOUT passing through the
26
+ // tool-failure auto-commit chokepoint in solve.mjs, so preserve (commit + push) any work the agent
27
+ // left on disk first. Gated by config (default on; HIVE_MIND_AUTO_COMMIT_ON_CRITICAL_ERROR=false).
28
+ // Best-effort: never let a commit failure mask the original error.
29
+ try {
30
+ const { criticalErrorRecovery } = await import('./config.lib.mjs');
31
+ if (criticalErrorRecovery.autoCommitUncommittedChanges && cleanupContext?.tempDir) {
32
+ const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
33
+ await commitUncommittedChangesOnCriticalError({
34
+ tempDir: cleanupContext.tempDir,
35
+ branchName: cleanupContext.branchName,
36
+ $,
37
+ log,
38
+ reason: `${errorType || 'execution'} error`,
39
+ });
40
+ }
41
+ } catch (preserveError) {
42
+ await log(` ⚠️ Could not auto-commit changes before failure exit: ${preserveError.message}`, { verbose: true });
43
+ }
44
+
24
45
  // Offer to create GitHub issue for the error
25
46
  try {
26
47
  await handleErrorWithIssueCreation({
@@ -117,7 +138,7 @@ export const handleFailure = async options => {
117
138
  * Creates an uncaught exception handler
118
139
  */
119
140
  export const createUncaughtExceptionHandler = options => {
120
- const { log, cleanErrorMessage, absoluteLogPath, shouldAttachLogs, argv, global, owner, repo, getLogFile, attachLogToGitHub, sanitizeLogContent, $ } = options;
141
+ const { log, cleanErrorMessage, absoluteLogPath, shouldAttachLogs, argv, global, owner, repo, getLogFile, attachLogToGitHub, sanitizeLogContent, cleanupContext, $ } = options;
121
142
 
122
143
  return async error => {
123
144
  await log(`\n❌ Uncaught Exception: ${cleanErrorMessage(error)}`, { level: 'error' });
@@ -136,6 +157,7 @@ export const createUncaughtExceptionHandler = options => {
136
157
  attachLogToGitHub,
137
158
  cleanErrorMessage,
138
159
  sanitizeLogContent,
160
+ cleanupContext,
139
161
  $,
140
162
  });
141
163
 
@@ -147,7 +169,7 @@ export const createUncaughtExceptionHandler = options => {
147
169
  * Creates an unhandled rejection handler
148
170
  */
149
171
  export const createUnhandledRejectionHandler = options => {
150
- const { log, cleanErrorMessage, absoluteLogPath, shouldAttachLogs, argv, global, owner, repo, getLogFile, attachLogToGitHub, sanitizeLogContent, $ } = options;
172
+ const { log, cleanErrorMessage, absoluteLogPath, shouldAttachLogs, argv, global, owner, repo, getLogFile, attachLogToGitHub, sanitizeLogContent, cleanupContext, $ } = options;
151
173
 
152
174
  return async reason => {
153
175
  await log(`\n❌ Unhandled Rejection: ${cleanErrorMessage(reason)}`, { level: 'error' });
@@ -166,6 +188,7 @@ export const createUnhandledRejectionHandler = options => {
166
188
  attachLogToGitHub,
167
189
  cleanErrorMessage,
168
190
  sanitizeLogContent,
191
+ cleanupContext,
169
192
  $,
170
193
  });
171
194
 
@@ -219,7 +242,7 @@ export const handleNoPrAvailableError = async ({ isContinueMode, tempDir, issueN
219
242
  * Handles execution errors in the main catch block
220
243
  */
221
244
  export const handleMainExecutionError = async options => {
222
- const { error, log, cleanErrorMessage, absoluteLogPath, shouldAttachLogs, argv, global, owner, repo, getLogFile, attachLogToGitHub, sanitizeLogContent, $ } = options;
245
+ const { error, log, cleanErrorMessage, absoluteLogPath, shouldAttachLogs, argv, global, owner, repo, getLogFile, attachLogToGitHub, sanitizeLogContent, cleanupContext, $ } = options;
223
246
 
224
247
  // Special handling for authentication errors
225
248
  if (error.isAuthError) {
@@ -256,6 +279,7 @@ export const handleMainExecutionError = async options => {
256
279
  attachLogToGitHub,
257
280
  cleanErrorMessage,
258
281
  sanitizeLogContent,
282
+ cleanupContext,
259
283
  $,
260
284
  });
261
285
 
package/src/solve.mjs CHANGED
@@ -21,7 +21,7 @@ const fs = (await use('fs')).promises;
21
21
  const crypto = (await use('crypto')).default;
22
22
  const memoryCheck = await import('./memory-check.mjs');
23
23
  const lib = await import('./lib.mjs');
24
- const { log, setLogFile, getLogFile, getAbsoluteLogPath, cleanErrorMessage, formatAligned, getVersionInfo, setupVerboseLogInterceptor, setupStdioLogInterceptor } = lib;
24
+ const { log, setLogFile, getLogFile, getAbsoluteLogPath, cleanErrorMessage, formatAligned, formatToolExecutionFailure, getVersionInfo, setupVerboseLogInterceptor, setupStdioLogInterceptor } = lib;
25
25
  const githubLib = await import('./github.lib.mjs');
26
26
  const { sanitizeLogContent, attachLogToGitHub, getToolDisplayName } = githubLib;
27
27
  const validation = await import('./solve.validation.lib.mjs');
@@ -181,9 +181,8 @@ const { isIssueUrl, isPrUrl, normalizedUrl, owner, repo, number: urlNumber } = u
181
181
  issueUrl = normalizedUrl || issueUrl;
182
182
  global.owner = owner;
183
183
  global.repo = repo;
184
- // Issue #1752: failures before PR creation can happen during checks that run
185
- // before the normal issue-mode setup below. Record the source issue as soon as
186
- // the URL is validated so the pre-exit notifier can still comment on it.
184
+ // Issue #1752: record the source issue as soon as the URL is validated so the pre-exit
185
+ // notifier can still comment on it if a check fails before normal issue-mode setup below.
187
186
  if (isIssueUrl) {
188
187
  global.issueNumber = urlNumber;
189
188
  }
@@ -193,8 +192,7 @@ if (argv.autoLanguage) {
193
192
  const { applyAutoLanguageToArgv } = await import('./auto-language.lib.mjs');
194
193
  await applyAutoLanguageToArgv({ argv, githubLib, owner, repo, number: urlNumber, isIssueUrl, isPrUrl, log });
195
194
  }
196
- // Initialize i18n based on --language / --ui-language / --work-language
197
- // (or detected system locale). --auto-language may set only the work track.
195
+ // Initialize i18n from --language / --ui-language / --work-language (or system locale).
198
196
  const { initI18n } = await import('./i18n.lib.mjs');
199
197
  await initI18n({
200
198
  language: argv.language,
@@ -209,6 +207,7 @@ const errorHandlerOptions = {
209
207
  shouldAttachLogs,
210
208
  argv,
211
209
  global,
210
+ cleanupContext, // #1845: mutated in place; lets exception handlers auto-commit uncommitted work
212
211
  owner: null, // Will be set later when parsed
213
212
  repo: null, // Will be set later when parsed
214
213
  getLogFile,
@@ -1072,6 +1071,8 @@ try {
1072
1071
  // 2. Autonomous claude - one-shot claude --resume w/ --dangerously-skip-permissions -p (claude only)
1073
1072
  // 3. Solve resume - re-enters solve.mjs with --resume, preserving tool/model/dir
1074
1073
  const toolForFailure = argv.tool || 'claude';
1074
+ // Issue #1845: surface the core error instead of just "<TOOL> execution failed" (terminal + comment).
1075
+ const toolFailureMessage = formatToolExecutionFailure({ tool: toolForFailure, toolResult });
1075
1076
  if (sessionId) {
1076
1077
  await log('');
1077
1078
  await log('💡 To continue this session:');
@@ -1116,7 +1117,7 @@ try {
1116
1117
  // Include sessionId so the PR comment can present it
1117
1118
  sessionId,
1118
1119
  // If not a usage limit case, fall back to generic failure format
1119
- errorMessage: limitReached ? undefined : `${argv.tool.toUpperCase()} execution failed`,
1120
+ errorMessage: limitReached ? undefined : toolFailureMessage,
1120
1121
  requestedModel: argv.originalModel || argv.model,
1121
1122
  tool: argv.tool || 'claude',
1122
1123
  // Issue #1454: Pass resultModelUsage for accurate multi-model display
@@ -1136,21 +1137,20 @@ try {
1136
1137
  }
1137
1138
  }
1138
1139
 
1139
- // Issue #1834 (PR #1835 feedback): "on all critical errors we auto commit uncommitted changes
1140
- // by default." A failed session is a critical error and exits here before the normal
1141
- // auto-commit chokepoint below, so preserve (commit + push) any work the agent left on disk
1142
- // first. On by default; disable via HIVE_MIND_AUTO_COMMIT_ON_CRITICAL_ERROR=false. Never throws.
1140
+ // Issue #1834 (PR #1835 feedback): "on all critical errors we auto commit uncommitted changes by
1141
+ // default." A failed session exits here before the normal auto-commit chokepoint below, so commit
1142
+ // + push any work first. On by default; disable via HIVE_MIND_AUTO_COMMIT_ON_CRITICAL_ERROR=false.
1143
1143
  try {
1144
1144
  const { criticalErrorRecovery } = await import('./config.lib.mjs');
1145
1145
  if (criticalErrorRecovery.autoCommitUncommittedChanges) {
1146
1146
  const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
1147
- await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: `${argv.tool || 'claude'} execution failed` });
1147
+ await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: toolFailureMessage });
1148
1148
  }
1149
1149
  } catch (preserveError) {
1150
1150
  await log(` ⚠️ Could not auto-commit before failure exit: ${preserveError.message}`, { verbose: true });
1151
1151
  }
1152
1152
 
1153
- await safeExit(1, `${argv.tool.toUpperCase()} execution failed`);
1153
+ await safeExit(1, toolFailureMessage);
1154
1154
  }
1155
1155
 
1156
1156
  // Clean up .playwright-mcp/ to prevent browser artifacts from triggering auto-restart (Issue #1124)
@@ -1463,6 +1463,7 @@ try {
1463
1463
  }
1464
1464
  await handleMainExecutionError({
1465
1465
  error,
1466
+ cleanupContext, // #1845: enable auto-commit of uncommitted work before the failure exit
1466
1467
  log,
1467
1468
  cleanErrorMessage,
1468
1469
  absoluteLogPath,
@@ -29,7 +29,7 @@ const fs = (await use('fs')).promises;
29
29
 
30
30
  // Import shared library functions
31
31
  const lib = await import('./lib.mjs');
32
- const { log, formatAligned } = lib;
32
+ const { log, formatAligned, extractToolErrorCore } = lib;
33
33
 
34
34
  // Import Sentry integration
35
35
  const sentryLib = await import('./sentry.lib.mjs');
@@ -507,11 +507,20 @@ export const buildUncommittedChangesFeedback = (changes, restartCount = 0, maxIt
507
507
  * @returns {boolean}
508
508
  */
509
509
  export const isApiError = toolResult => {
510
- if (!toolResult || !toolResult.result) return false;
510
+ if (!toolResult) return false;
511
+
512
+ // Issue #1845: runners report failures via `errorInfo` (e.g. claude sets
513
+ // `errorInfo.message` but NOT `result`). Use the shared core-error extractor so an
514
+ // "API Error:" is classified correctly regardless of which field the runner populated —
515
+ // otherwise the MAX_API_ERROR_RETRIES guard never trips for claude and watch mode can
516
+ // retry a hard API error indefinitely. `extractToolErrorCore` still falls back to
517
+ // `result`, preserving the original behavior for runners that set it.
518
+ const errorText = extractToolErrorCore({ toolResult });
519
+ if (!errorText) return false;
511
520
 
512
521
  const errorPatterns = ['API Error:', 'not_found_error', 'authentication_error', 'invalid_request_error'];
513
522
 
514
- return errorPatterns.some(pattern => toolResult.result.includes(pattern));
523
+ return errorPatterns.some(pattern => errorText.includes(pattern));
515
524
  };
516
525
 
517
526
  /**
@@ -20,7 +20,7 @@ const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
20
20
  const $ = wrapDollarWithGhRetry(__rawDollar$);
21
21
  // Import shared library functions
22
22
  const lib = await import('./lib.mjs');
23
- const { log, cleanErrorMessage, formatAligned, getLogFile } = lib;
23
+ const { log, cleanErrorMessage, formatAligned, formatToolExecutionFailure, extractToolErrorCore, getLogFile } = lib;
24
24
 
25
25
  // Import feedback detection functions
26
26
  const feedbackLib = await import('./solve.feedback.lib.mjs');
@@ -373,7 +373,9 @@ export const watchForFeedback = async params => {
373
373
  if (consecutiveApiErrors >= MAX_API_ERROR_RETRIES) {
374
374
  await log('');
375
375
  await log(formatAligned('❌', 'MAXIMUM API ERROR RETRIES REACHED', ''));
376
- await log(formatAligned('', 'Error details:', toolResult.result || 'Unknown API error', 2));
376
+ // Issue #1845: surface the core error (e.g. "API Error: Output blocked by content
377
+ // filtering policy"); toolResult.result is often unset on failure, so prefer errorInfo.
378
+ await log(formatAligned('', 'Error details:', extractToolErrorCore({ toolResult }) || 'Unknown API error', 2));
377
379
  await log(formatAligned('', 'Consecutive failures:', `${consecutiveApiErrors}`, 2));
378
380
  await log(formatAligned('', 'Action:', 'Exiting watch mode to prevent infinite loop', 2));
379
381
  await log('');
@@ -421,7 +423,7 @@ export const watchForFeedback = async params => {
421
423
  sessionId: toolResult.sessionId || latestSessionId,
422
424
  tempDir,
423
425
  // Include error information in the log upload
424
- errorMessage: toolResult.errorInfo?.message || toolResult.result || `${argv.tool.toUpperCase()} execution failed`,
426
+ errorMessage: formatToolExecutionFailure({ tool: argv.tool, toolResult }),
425
427
  // Include pricing data if available from failed attempt
426
428
  publicPricingEstimate: toolResult.publicPricingEstimate,
427
429
  pricingInfo: toolResult.pricingInfo,
@@ -64,6 +64,114 @@ export function formatQueueHistorySection({ items, emoji, label, max, locale, wi
64
64
  return `${section}\n`;
65
65
  }
66
66
 
67
+ /**
68
+ * Normalize an issue/PR URL for de-duplication: drop a trailing slash, drop any
69
+ * `#fragment`, and lowercase. Two URLs that point at the same issue/PR collapse
70
+ * to the same key so an item that is both in the queue's in-memory `processing`
71
+ * Map and in the tracked-session list is listed only once (issue #1837).
72
+ *
73
+ * @param {string} url
74
+ * @returns {string}
75
+ */
76
+ function normalizeQueueUrl(url) {
77
+ return typeof url === 'string' ? url.replace(/\/+$/, '').replace(/#.*$/, '').toLowerCase() : '';
78
+ }
79
+
80
+ /**
81
+ * Build the list of tasks a tool is actively *executing* for the detailed queue
82
+ * status, by merging the queue's in-memory `processing` items with the
83
+ * externally-tracked running sessions (detached screen/isolation work),
84
+ * de-duplicated by issue/PR URL.
85
+ *
86
+ * This is the fix for the follow-up on issue #1837: once a task is dispatched to
87
+ * a detached session the queue's own `processing` Map is emptied, so the running
88
+ * task — although still counted via `pgrep`/`$ --status` — was never listed.
89
+ * Pulling the tracked running sessions in here makes executing tasks show up as
90
+ * clickable links again.
91
+ *
92
+ * @param {object} opts
93
+ * @param {Iterable} [opts.processingItems] - `this.processing.values()` (each with `tool`, `url`, `status`, `getWaitTime()`).
94
+ * @param {Array} [opts.sessionItems] - Tracked running sessions (`{url, tool, startTime, status}`).
95
+ * @param {string} opts.tool - Tool key to filter by.
96
+ * @param {number} [opts.now] - Current epoch ms (injectable for tests).
97
+ * @returns {Array<{url: string, queueStatus: (string|null), waitMs: number}>}
98
+ */
99
+ export function collectExecutingItems({ processingItems = [], sessionItems = [], tool, now = Date.now() }) {
100
+ const byKey = new Map();
101
+
102
+ for (const item of processingItems) {
103
+ if (item.tool !== tool) continue;
104
+ const key = normalizeQueueUrl(item.url) || item.id;
105
+ byKey.set(key, {
106
+ url: item.url,
107
+ queueStatus: item.status || null,
108
+ waitMs: typeof item.getWaitTime === 'function' ? item.getWaitTime() : 0,
109
+ });
110
+ }
111
+
112
+ for (const session of sessionItems) {
113
+ if ((session.tool || 'claude') !== tool) continue;
114
+ if (!session.url) continue; // can't render a clickable link without a URL
115
+ const key = normalizeQueueUrl(session.url);
116
+ if (key && byKey.has(key)) continue; // already represented by an in-memory item
117
+ const startMs = session.startTime ? new Date(session.startTime).getTime() : null;
118
+ byKey.set(key || session.sessionName, {
119
+ url: session.url,
120
+ // Tracked sessions report a backend status (e.g. 'executing'); fall back to
121
+ // the generic "processing" label rendered by formatQueueProcessingItems.
122
+ queueStatus: null,
123
+ waitMs: startMs && !Number.isNaN(startMs) ? Math.max(0, now - startMs) : 0,
124
+ });
125
+ }
126
+
127
+ return [...byKey.values()];
128
+ }
129
+
130
+ /**
131
+ * Render the per-tool "executing" lines (`▶️ link (status, elapsed)`) for the
132
+ * detailed queue status, capped at `max` items with a localized "... and N more"
133
+ * line (issue #1837).
134
+ *
135
+ * @param {object} opts
136
+ * @param {Array} opts.items - Output of {@link collectExecutingItems}.
137
+ * @param {number} opts.max - Maximum items to list before collapsing.
138
+ * @param {string|null} opts.locale - Locale for labels/durations.
139
+ * @returns {string} The formatted lines (empty string when no items).
140
+ */
141
+ export function formatQueueProcessingItems({ items, max, locale }) {
142
+ if (!items || items.length === 0) return '';
143
+ let out = '';
144
+ for (const item of items.slice(0, max)) {
145
+ const label = item.queueStatus ? lt(`queue_status_${item.queueStatus}`, {}, { locale }) : lt('queue_processing', {}, { locale });
146
+ out += ` ▶️ ${formatQueueItemLink(item.url)} (${label}, ${formatDuration(item.waitMs, { locale })})\n`;
147
+ }
148
+ if (items.length > max) {
149
+ out += ` ... ${lt('queue_and_more', { count: items.length - max }, { locale })}\n`;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * Lazy wrapper around session-monitor's `getRunningSessionItems` so the queue
156
+ * can list executing detached sessions without a static import (mirrors how the
157
+ * queue lazily loads isolation-session counts). Returns an empty list on error
158
+ * so the detailed status still renders (issue #1837).
159
+ *
160
+ * @param {boolean} verbose - Whether to log verbose output
161
+ * @returns {Promise<Array>}
162
+ */
163
+ export async function getRunningSessionItems(verbose = false) {
164
+ try {
165
+ const { getRunningSessionItems: impl } = await import('./session-monitor.lib.mjs');
166
+ return await impl(verbose);
167
+ } catch (error) {
168
+ if (verbose) {
169
+ console.error('[VERBOSE] /solve_queue error getting running session items:', error.message);
170
+ }
171
+ return [];
172
+ }
173
+ }
174
+
67
175
  /**
68
176
  * Count running processes by name.
69
177
  * @param {string} processName - Process name to search for (e.g., 'claude', 'agent', 'codex', 'gemini')
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { getCachedClaudeLimits, getCachedCodexLimits, getCachedGitHubLimits, getCachedMemoryInfo, getCachedCpuInfo, getCachedDiskInfo, getLimitCache } from './limits.lib.mjs';
19
19
  export { formatDuration, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
- import { formatDuration, formatQueueHistorySection, formatQueueItemLink, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses } from './telegram-solve-queue.helpers.lib.mjs';
20
+ import { collectExecutingItems, formatDuration, formatQueueHistorySection, formatQueueItemLink, formatQueueProcessingItems, formatWaitingReason, getRunningAgentProcesses, getRunningClaudeProcesses, getRunningCodexProcesses, getRunningGeminiProcesses, getRunningProcesses, getRunningQwenProcesses, getRunningSessionItems } from './telegram-solve-queue.helpers.lib.mjs';
21
21
  export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
22
22
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
23
23
  import { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
@@ -164,6 +164,9 @@ export class SolveQueue {
164
164
  this.messageUpdateCallback = options.messageUpdateCallback || null;
165
165
  this.getRunningProcessesFn = options.getRunningProcesses || getRunningProcesses;
166
166
  this.getRunningIsolatedSessionsFn = options.getRunningIsolatedSessions || getRunningIsolatedSessions;
167
+ // Source of currently-executing detached sessions (with issue/PR URLs) used
168
+ // to list executing tasks in the detailed status (issue #1837).
169
+ this.getRunningSessionItemsFn = options.getRunningSessionItems || getRunningSessionItems;
167
170
  this.autoStart = options.autoStart !== false;
168
171
 
169
172
  // Separate queues per tool type - claude tasks never block other tool tasks
@@ -1336,6 +1339,10 @@ export class SolveQueue {
1336
1339
  const locale = getLocale(options);
1337
1340
  const stats = this.getStats();
1338
1341
  const externalProcessing = await this.getExternalProcessingSnapshot(Object.keys(this.queues));
1342
+ // Currently-executing detached sessions (with issue/PR URLs). These are the
1343
+ // real running tasks; the queue's own `processing` Map is emptied once a task
1344
+ // is dispatched, so without this the executing items are never listed (#1837).
1345
+ const runningSessionItems = await this.getRunningSessionItemsFn(this.verbose);
1339
1346
 
1340
1347
  // Get actual processing counts for each tool queue.
1341
1348
  // This combines pgrep with tracked isolation status so users see detached
@@ -1348,14 +1355,12 @@ export class SolveQueue {
1348
1355
  const processing = externalProcessing.byTool[tool] || 0;
1349
1356
  message += `*${tool}* (${lt('queue_pending', {}, { locale })}: ${pending}, ${lt('queue_processing', {}, { locale })}: ${processing})\n`;
1350
1357
 
1351
- // Show the items this queue is actively processing for this tool, with a
1352
- // clickable link to each issue/PR (issue #1837). These come from the
1353
- // queue's own tracking, so they may differ from the pgrep-based count above.
1354
- const processingItems = Array.from(this.processing.values()).filter(item => item.tool === tool);
1355
- for (const item of processingItems.slice(0, QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE)) {
1356
- const waitTime = formatDuration(item.getWaitTime(), { locale });
1357
- message += ` ▶️ ${formatQueueItemLink(item.url)} (${queueStatusLabel(item.status, locale)}, ${waitTime})\n`;
1358
- }
1358
+ // List the tasks this tool is actively executing as clickable links. We
1359
+ // merge the queue's in-memory processing Map with the externally-tracked
1360
+ // running sessions (detached screen/isolation work), deduped by URL, so
1361
+ // executing tasks are listed even after dispatch (issue #1837).
1362
+ const executing = collectExecutingItems({ processingItems: this.processing.values(), sessionItems: runningSessionItems, tool });
1363
+ message += formatQueueProcessingItems({ items: executing, max: QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE, locale });
1359
1364
 
1360
1365
  // Show first queued items for this tool with clickable links
1361
1366
  const displayItems = toolQueue.slice(0, QUEUE_CONFIG.MAX_DISPLAY_ITEMS_PER_QUEUE);