@link-assistant/hive-mind 1.35.5 → 1.35.7

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,21 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.35.7
4
+
5
+ ### Patch Changes
6
+
7
+ - fca8460: fix: prevent infinite CI waiting loop when workflows complete with action_required (Issue #1466)
8
+ - Detect when all workflow runs completed with non-executing conclusions (action_required, cancelled, stale, skipped) and treat as "CI not triggered" instead of waiting indefinitely for check-runs that will never appear
9
+ - Add verbose log interceptor (setupVerboseLogInterceptor) to capture [VERBOSE] console.log output in log files, fixing the discrepancy between terminal and log file output
10
+ - Add case study with root cause analysis and timeline reconstruction from 5 production log files
11
+ - Add 14 unit tests covering action_required handling, non-executing conclusions, race conditions, and edge cases
12
+
13
+ ## 1.35.6
14
+
15
+ ### Patch Changes
16
+
17
+ - 4b0beaf: Fix interactive mode PR comment output: use stdin for GitHub API calls to prevent shell quoting corruption, flush comment queue before tool result timeout to prevent stuck "Waiting for result..." comments, and guard against duplicate session started comments from late system.init events
18
+
3
19
  ## 1.35.5
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.35.5",
3
+ "version": "1.35.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/hive.mjs CHANGED
@@ -95,7 +95,7 @@ if (isDirectExecution) {
95
95
  const fs = (await withTimeout(use('fs'), 30000, 'loading fs')).promises;
96
96
  // Import shared library functions
97
97
  const lib = await import('./lib.mjs');
98
- const { log, setLogFile, getAbsoluteLogPath, formatTimestamp, cleanErrorMessage, cleanupTempDirectories } = lib;
98
+ const { log, setLogFile, getAbsoluteLogPath, formatTimestamp, cleanErrorMessage, cleanupTempDirectories, setupVerboseLogInterceptor } = lib;
99
99
  const yargsConfigLib = await import('./hive.config.lib.mjs');
100
100
  const { createYargsConfig } = yargsConfigLib;
101
101
  const claudeLib = await import('./claude.lib.mjs');
@@ -311,6 +311,9 @@ if (isDirectExecution) {
311
311
  // Set global verbose mode
312
312
  global.verboseMode = argv.verbose;
313
313
 
314
+ // Issue #1466: Intercept console.log to capture [VERBOSE] output in log files
315
+ setupVerboseLogInterceptor();
316
+
314
317
  // Use the universal GitHub URL parser
315
318
  if (githubUrl) {
316
319
  const parsedUrl = parseGitHubUrl(githubUrl);
@@ -52,6 +52,13 @@ const CONFIG = {
52
52
  // See: https://github.com/link-assistant/hive-mind/issues/1324
53
53
  import { sanitizeUnicode } from './unicode-sanitization.lib.mjs';
54
54
 
55
+ // Use child_process for stdin-based API calls to avoid shell quoting issues
56
+ // with large/complex comment bodies containing backticks, quotes, etc.
57
+ // See: https://github.com/link-assistant/hive-mind/issues/1458
58
+ import { execFile } from 'node:child_process';
59
+ import { promisify } from 'node:util';
60
+ const execFileAsync = promisify(execFile);
61
+
55
62
  /**
56
63
  * Truncate content in the middle, keeping start and end
57
64
  * This helps show context while reducing size for large outputs
@@ -237,7 +244,9 @@ const getToolIcon = toolName => {
237
244
  * @returns {Object} Handler object with event processing methods
238
245
  */
239
246
  export const createInteractiveHandler = options => {
240
- const { owner, repo, prNumber, $, log, verbose = false } = options;
247
+ const { owner, repo, prNumber, log, verbose = false, execFile: execFileFn } = options;
248
+ // Use injected execFile for testability, or the real one by default
249
+ const runGhApi = execFileFn || execFileAsync;
241
250
 
242
251
  // State tracking for the handler
243
252
  const state = {
@@ -291,24 +300,35 @@ export const createInteractiveHandler = options => {
291
300
  }
292
301
 
293
302
  try {
294
- // Post comment and capture the output to get the comment URL/ID
295
- const result = await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${body}`;
303
+ // Post comment via gh api with stdin to avoid shell quoting issues
304
+ // with complex markdown bodies containing backticks, quotes, etc.
305
+ // See: https://github.com/link-assistant/hive-mind/issues/1458
306
+ const apiUrl = `repos/${owner}/${repo}/issues/${prNumber}/comments`;
307
+ const jsonPayload = JSON.stringify({ body });
308
+ const { stdout } = await runGhApi('gh', ['api', apiUrl, '-X', 'POST', '--input', '-'], {
309
+ input: jsonPayload,
310
+ maxBuffer: 10 * 1024 * 1024, // 10MB
311
+ });
296
312
  state.lastCommentTime = Date.now();
297
313
 
298
- // Extract comment ID from the result (gh outputs the comment URL)
299
- // Format: https://github.com/owner/repo/pull/123#issuecomment-1234567890
300
- // Note: command-stream returns stdout as a Buffer, so we need to call .toString()
301
- const output = result.stdout?.toString() || result.toString() || '';
302
- const match = output.match(/issuecomment-(\d+)/);
303
- const commentId = match ? match[1] : null;
314
+ // Extract comment ID from the API response JSON
315
+ let commentId = null;
316
+ try {
317
+ const response = JSON.parse(stdout);
318
+ commentId = response.id ? String(response.id) : null;
319
+ } catch {
320
+ // Fallback: try to extract from URL pattern
321
+ const match = stdout.match(/issuecomment-(\d+)|"id":\s*(\d+)/);
322
+ commentId = match ? match[1] || match[2] : null;
323
+ }
304
324
 
305
325
  if (verbose) {
306
- await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''}`, { verbose: true });
326
+ await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''} (body: ${body.length} chars)`, { verbose: true });
307
327
  }
308
328
  return commentId;
309
329
  } catch (error) {
310
330
  if (verbose) {
311
- await log(`⚠️ Interactive mode: Failed to post comment: ${error.message}`, { verbose: true });
331
+ await log(`⚠️ Interactive mode: Failed to post comment: ${error.message} (body: ${body.length} chars)`, { verbose: true });
312
332
  }
313
333
  return null;
314
334
  }
@@ -330,14 +350,22 @@ export const createInteractiveHandler = options => {
330
350
  }
331
351
 
332
352
  try {
333
- await $`gh api repos/${owner}/${repo}/issues/comments/${commentId} -X PATCH -f body=${body}`;
353
+ // Edit comment via gh api with stdin to avoid shell quoting issues
354
+ // with complex markdown bodies containing backticks, quotes, etc.
355
+ // See: https://github.com/link-assistant/hive-mind/issues/1458
356
+ const apiUrl = `repos/${owner}/${repo}/issues/comments/${commentId}`;
357
+ const jsonPayload = JSON.stringify({ body });
358
+ await runGhApi('gh', ['api', apiUrl, '-X', 'PATCH', '--input', '-'], {
359
+ input: jsonPayload,
360
+ maxBuffer: 10 * 1024 * 1024, // 10MB
361
+ });
334
362
  if (verbose) {
335
- await log(`✅ Interactive mode: Comment ${commentId} updated`, { verbose: true });
363
+ await log(`✅ Interactive mode: Comment ${commentId} updated (body: ${body.length} chars, payload: ${jsonPayload.length} chars)`, { verbose: true });
336
364
  }
337
365
  return true;
338
366
  } catch (error) {
339
367
  if (verbose) {
340
- await log(`⚠️ Interactive mode: Failed to edit comment: ${error.message}`, { verbose: true });
368
+ await log(`⚠️ Interactive mode: Failed to edit comment ${commentId}: ${error.message} (body: ${body.length} chars)`, { verbose: true });
341
369
  }
342
370
  return false;
343
371
  }
@@ -398,6 +426,16 @@ export const createInteractiveHandler = options => {
398
426
  * @param {Object} data - Event data
399
427
  */
400
428
  const handleSystemInit = async data => {
429
+ // Guard against duplicate init events (e.g., when a late task_notification
430
+ // arrives after the result event and triggers a new conversation turn)
431
+ // See: https://github.com/link-assistant/hive-mind/issues/1458
432
+ if (state.sessionId) {
433
+ if (verbose) {
434
+ await log(`⚠️ Interactive mode: Ignoring duplicate system.init event (session already initialized: ${state.sessionId})`, { verbose: true });
435
+ }
436
+ return;
437
+ }
438
+
401
439
  state.sessionId = data.session_id;
402
440
  state.startTime = Date.now();
403
441
 
@@ -672,21 +710,44 @@ ${createRawJsonSection(data)}`;
672
710
  // If comment ID is not yet available (comment was queued), wait for it
673
711
  // But use a timeout to avoid blocking forever
674
712
  if (!commentId && commentIdPromise) {
675
- if (verbose) {
676
- await log(`⏳ Interactive mode: Waiting for tool use comment to be posted (tool: ${toolUseId})`, {
677
- verbose: true,
678
- });
713
+ // First, try to flush the queue — the tool_use comment may still be
714
+ // waiting for rate-limit clearance. Processing it here avoids the 30s
715
+ // timeout that previously caused many comments to stay stuck on
716
+ // "Waiting for result...".
717
+ // See: https://github.com/link-assistant/hive-mind/issues/1458
718
+ if (state.commentQueue.length > 0) {
719
+ if (verbose) {
720
+ await log(`🔄 Interactive mode: Flushing comment queue (${state.commentQueue.length} items) before waiting for tool use comment`, {
721
+ verbose: true,
722
+ });
723
+ }
724
+ // Temporarily reset isProcessing to allow processQueue to run
725
+ const wasProcessing = state.isProcessing;
726
+ state.isProcessing = false;
727
+ await processQueue();
728
+ state.isProcessing = wasProcessing;
679
729
  }
680
- // Wait for the comment to be posted (with 30 second timeout)
681
- const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), 30000));
682
- commentId = await Promise.race([commentIdPromise, timeoutPromise]);
730
+
731
+ // Check again after queue flush
732
+ commentId = pendingCall.commentId;
683
733
 
684
734
  if (!commentId) {
685
735
  if (verbose) {
686
- await log('⚠️ Interactive mode: Timeout waiting for tool use comment, posting result separately', {
736
+ await log(`⏳ Interactive mode: Waiting for tool use comment to be posted (tool: ${toolUseId})`, {
687
737
  verbose: true,
688
738
  });
689
739
  }
740
+ // Wait for the comment to be posted (with 30 second timeout)
741
+ const timeoutPromise = new Promise(resolve => setTimeout(() => resolve(null), 30000));
742
+ commentId = await Promise.race([commentIdPromise, timeoutPromise]);
743
+
744
+ if (!commentId) {
745
+ if (verbose) {
746
+ await log('⚠️ Interactive mode: Timeout waiting for tool use comment, posting result separately', {
747
+ verbose: true,
748
+ });
749
+ }
750
+ }
690
751
  }
691
752
  }
692
753
 
package/src/lib.mjs CHANGED
@@ -114,6 +114,43 @@ export const log = async (message, options = {}) => {
114
114
  }
115
115
  };
116
116
 
117
+ /**
118
+ * Issue #1466: Intercept console.log to capture [VERBOSE] output in the log file.
119
+ *
120
+ * Functions in github-merge.lib.mjs and github-merge-ci.lib.mjs use console.log()
121
+ * directly for verbose output (e.g., `console.log('[VERBOSE] /merge: ...')`).
122
+ * This means verbose diagnostic data only appears in the terminal, not in log files,
123
+ * making debugging harder.
124
+ *
125
+ * This interceptor wraps console.log so that any message containing '[VERBOSE]'
126
+ * is also appended to the log file. It preserves the original console.log behavior.
127
+ *
128
+ * Call this once after setLogFile() to enable the interceptor.
129
+ */
130
+ let verboseInterceptorInstalled = false;
131
+ export const setupVerboseLogInterceptor = () => {
132
+ if (verboseInterceptorInstalled) return;
133
+ verboseInterceptorInstalled = true;
134
+
135
+ const originalConsoleLog = console.log.bind(console);
136
+ console.log = (...args) => {
137
+ // Always call original console.log first
138
+ originalConsoleLog(...args);
139
+
140
+ // If a log file is set and the message looks like a [VERBOSE] log, append to file
141
+ if (logFile && args.length > 0) {
142
+ const firstArg = String(args[0]);
143
+ if (firstArg.includes('[VERBOSE]')) {
144
+ const message = args.map(a => String(a)).join(' ');
145
+ const logMessage = `[${new Date().toISOString()}] [VERBOSE] ${message}`;
146
+ fs.appendFile(logFile, logMessage + '\n').catch(() => {
147
+ // Silent fail to avoid infinite loops
148
+ });
149
+ }
150
+ }
151
+ };
152
+ };
153
+
117
154
  /**
118
155
  * Mask sensitive tokens in text
119
156
  * @param {string} token - Token to mask
@@ -469,6 +506,7 @@ export default {
469
506
  formatAligned,
470
507
  displayFormattedError,
471
508
  cleanupTempDirectories,
509
+ setupVerboseLogInterceptor,
472
510
  };
473
511
 
474
512
  /**
@@ -224,7 +224,28 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false) => {
224
224
  // - workflow_runs.length === 0 → CI was NOT triggered (fork PR, paths-ignore, etc.)
225
225
  const workflowRuns = await getWorkflowRunsForSha(owner, repo, ciStatus.sha, verbose);
226
226
  if (workflowRuns.length > 0) {
227
- // Workflow runs exist but check-runs haven't appeared yet genuine race condition
227
+ // Issue #1466: Check if ALL workflow runs are completed without producing check-runs.
228
+ // This happens when workflows require manual approval (first-time fork contributors,
229
+ // deployment approvals) — they complete with conclusion=action_required but never
230
+ // create check-runs. Waiting for check-runs in this case is an infinite loop.
231
+ //
232
+ // Also covers other non-executing conclusions: cancelled, stale workflows that
233
+ // completed without producing check-runs won't produce them in the future either.
234
+ const allRunsCompleted = workflowRuns.every(r => r.status === 'completed');
235
+ const allRunsNonExecuting = allRunsCompleted && workflowRuns.every(r => r.conclusion === 'action_required' || r.conclusion === 'cancelled' || r.conclusion === 'stale' || r.conclusion === 'skipped');
236
+
237
+ if (allRunsNonExecuting) {
238
+ // All workflow runs completed without executing jobs — check-runs will never appear.
239
+ // Treat the same as "CI not triggered" to avoid infinite waiting.
240
+ const conclusions = [...new Set(workflowRuns.map(r => r.conclusion))].join(', ');
241
+ if (verbose) {
242
+ console.log(`[VERBOSE] /merge: PR #${prNumber} has ${workflowRuns.length} workflow run(s) for SHA ${ciStatus.sha.substring(0, 7)}, but all completed without executing (conclusions: ${conclusions}) — check-runs will never appear`);
243
+ }
244
+ await log(formatAligned('ℹ️', 'CI workflows completed without executing:', `${conclusions} (${workflowRuns.map(r => r.name).join(', ')})`, 2));
245
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true, workflowRunConclusions: conclusions };
246
+ }
247
+
248
+ // Some workflow runs are still in progress or produced results — genuine race condition
228
249
  if (verbose) {
229
250
  console.log(`[VERBOSE] /merge: PR #${prNumber} has no CI check-runs yet, but ${workflowRuns.length} workflow run(s) were triggered for SHA ${ciStatus.sha.substring(0, 7)} - genuine race condition (waiting for check-runs to appear)`);
230
251
  }
@@ -414,7 +435,7 @@ export const watchUntilMergeable = async params => {
414
435
 
415
436
  try {
416
437
  // Get merge blockers
417
- const { blockers, noCiConfigured, noCiTriggered } = await getMergeBlockers(owner, repo, prNumber, argv.verbose);
438
+ const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions } = await getMergeBlockers(owner, repo, prNumber, argv.verbose);
418
439
 
419
440
  // Check for new comments from non-bot users
420
441
  const { hasNewComments, comments } = await checkForNonBotComments(owner, repo, prNumber, issueNumber, lastCheckTime, argv.verbose);
@@ -422,10 +443,15 @@ export const watchUntilMergeable = async params => {
422
443
  // Check for uncommitted changes using shared utility
423
444
  const hasUncommittedChanges = await checkForUncommittedChanges(tempDir, argv);
424
445
 
425
- // Issue #1442: If CI workflows exist but were not triggered for this commit,
446
+ // Issue #1442/#1466: If CI workflows exist but were not triggered for this commit,
426
447
  // log why before proceeding to the mergeable path.
427
448
  if (noCiTriggered) {
428
- await log(formatAligned('ℹ️', 'CI not triggered:', 'Workflows exist but no workflow runs for this commit (fork PR, paths-ignore, workflow conditions)', 2));
449
+ if (workflowRunConclusions) {
450
+ // Issue #1466: Workflow runs exist but completed without executing (action_required, cancelled, etc.)
451
+ await log(formatAligned('ℹ️', 'CI not executed:', `Workflow runs completed with: ${workflowRunConclusions} (likely needs maintainer approval)`, 2));
452
+ } else {
453
+ await log(formatAligned('ℹ️', 'CI not triggered:', 'Workflows exist but no workflow runs for this commit (fork PR, paths-ignore, workflow conditions)', 2));
454
+ }
429
455
  }
430
456
 
431
457
  // If PR is mergeable, no blockers, no new comments, and no uncommitted changes
@@ -444,7 +470,7 @@ export const watchUntilMergeable = async params => {
444
470
  // Post success comment
445
471
  try {
446
472
  // Issue #1345: Differentiate message when no CI is configured
447
- const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? '- CI workflows exist but were not triggered for this commit' : '- All CI checks have passed';
473
+ const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? (workflowRunConclusions ? `- CI workflows completed without executing (${workflowRunConclusions})` : '- CI workflows exist but were not triggered for this commit') : '- All CI checks have passed';
448
474
  const commentBody = `## 🎉 Auto-merged\n\nThis pull request has been automatically merged by hive-mind.\n${ciLine}\n\n---\n*Auto-merged by hive-mind with --auto-merge flag*`;
449
475
  await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
450
476
  } catch {
@@ -468,7 +494,7 @@ export const watchUntilMergeable = async params => {
468
494
  try {
469
495
  if (!readyToMergeCommentPosted) {
470
496
  // Issue #1345: Differentiate message when no CI is configured
471
- const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? '- CI workflows exist but were not triggered for this commit' : '- All CI checks have passed';
497
+ const ciLine = noCiConfigured ? '- No CI/CD checks are configured for this repository' : noCiTriggered ? (workflowRunConclusions ? `- CI workflows completed without executing (${workflowRunConclusions})` : '- CI workflows exist but were not triggered for this commit') : '- All CI checks have passed';
472
498
  const commentBody = `## ✅ Ready to merge\n\nThis pull request is now ready to be merged:\n${ciLine}\n- No merge conflicts\n- No pending changes\n\n---\n*Monitored by hive-mind with --auto-restart-until-mergeable flag*`;
473
499
  await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
474
500
  readyToMergeCommentPosted = true;
package/src/solve.mjs CHANGED
@@ -48,7 +48,7 @@ const fs = (await use('fs')).promises;
48
48
  const crypto = (await use('crypto')).default;
49
49
  const memoryCheck = await import('./memory-check.mjs');
50
50
  const lib = await import('./lib.mjs');
51
- const { log, setLogFile, getLogFile, getAbsoluteLogPath, cleanErrorMessage, formatAligned, getVersionInfo } = lib;
51
+ const { log, setLogFile, getLogFile, getAbsoluteLogPath, cleanErrorMessage, formatAligned, getVersionInfo, setupVerboseLogInterceptor } = lib;
52
52
  const githubLib = await import('./github.lib.mjs');
53
53
  const { sanitizeLogContent, attachLogToGitHub, getToolDisplayName } = githubLib;
54
54
  const validation = await import('./solve.validation.lib.mjs');
@@ -111,6 +111,9 @@ try {
111
111
  }
112
112
  global.verboseMode = argv.verbose;
113
113
 
114
+ // Issue #1466: Intercept console.log to capture [VERBOSE] output in log files
115
+ setupVerboseLogInterceptor();
116
+
114
117
  // Early logs go to cwd; custom log dir takes effect after argv is parsed
115
118
 
116
119
  // Conditionally import tool-specific functions after argv is parsed