@link-assistant/hive-mind 1.24.2 → 1.24.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,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.24.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 40282f3: fix: escape '...' ellipsis in MarkdownV2 and retry on UNKNOWN merge state (Issue #1339)
8
+
9
+ Two root causes fixed:
10
+ 1. **MarkdownV2 escaping**: In `formatProgressMessage()`, literal '...' was appended in PR titles, error messages, and overflow lines. Telegram's MarkdownV2 requires '.' to be escaped as '\.' - unescaped periods caused 400 Bad Request errors on every message update during CI wait.
11
+ 2. **UNKNOWN merge state**: GitHub computes PR mergeability asynchronously, so initial queries may return `mergeStateStatus: 'UNKNOWN'`. The old code immediately skipped PRs in this state. Fixed by adding retry logic to `checkPRMergeable()` that retries up to 3 times with 5-second delays before giving up.
12
+
13
+ ## 1.24.3
14
+
15
+ ### Patch Changes
16
+
17
+ - 297e07c: Fix incorrect iteration counter and duplicate comments in auto-restart mode
18
+ - Fixed iteration counter to show actual AI restart count instead of check cycle number
19
+ - Added deduplication check to prevent duplicate "Ready to merge" status comments
20
+ - Added case study documentation for issue #1323
21
+
3
22
  ## 1.24.2
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -811,9 +811,21 @@ procinfo 62220
811
811
 
812
812
  ## Maintenance
813
813
 
814
- ### Reboot server.
814
+ ### Enter latest screen
815
+
816
+ ```bash
817
+ s=$(screen -ls | awk '/Detached/ {print $1; exit}'); echo "Entering $s"; screen -r "$s"; echo "Left $s";
818
+ ```
819
+
820
+ ### Enter oldest screen
815
821
 
822
+ ```bash
823
+ s=$(screen -ls | awk '/Detached/ {last=$1} END{print last}'); echo "Entering $s"; screen -r "$s"; echo "Left $s";
816
824
  ```
825
+
826
+ ### Reboot server.
827
+
828
+ ```bash
817
829
  sudo reboot
818
830
  ```
819
831
 
@@ -821,7 +833,7 @@ That will remove all dangling unused proccesses and screens, which will in turn
821
833
 
822
834
  ### Cleanup disk space.
823
835
 
824
- ```
836
+ ```bash
825
837
  df -h
826
838
 
827
839
  rm -rf /tmp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.24.2",
3
+ "version": "1.24.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",
@@ -400,6 +400,11 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
400
400
 
401
401
  /**
402
402
  * Check if PR is mergeable
403
+ *
404
+ * Issue #1339: GitHub computes mergeability asynchronously. The first request may return
405
+ * mergeable: null and mergeStateStatus: 'UNKNOWN' while the computation is in progress.
406
+ * We retry up to 3 times with a 5-second delay between attempts to handle this case.
407
+ *
403
408
  * @param {string} owner - Repository owner
404
409
  * @param {string} repo - Repository name
405
410
  * @param {number} prNumber - Pull request number
@@ -407,46 +412,73 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
407
412
  * @returns {Promise<{mergeable: boolean, reason: string|null}>}
408
413
  */
409
414
  export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
410
- try {
411
- const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
412
- const pr = JSON.parse(stdout.trim());
413
-
414
- const mergeable = pr.mergeable === 'MERGEABLE';
415
- let reason = null;
416
-
417
- if (!mergeable) {
418
- switch (pr.mergeStateStatus) {
419
- case 'BLOCKED':
420
- reason = 'PR is blocked (possibly by branch protection rules)';
421
- break;
422
- case 'BEHIND':
423
- reason = 'PR branch is behind the base branch';
424
- break;
425
- case 'DIRTY':
426
- reason = 'PR has merge conflicts';
427
- break;
428
- case 'UNSTABLE':
429
- reason = 'PR has failing required status checks';
430
- break;
431
- case 'DRAFT':
432
- reason = 'PR is a draft';
433
- break;
434
- default:
435
- reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
415
+ // Issue #1339: GitHub computes mergeability asynchronously. When mergeStateStatus is
416
+ // 'UNKNOWN', it means GitHub hasn't calculated the merge state yet. Retry a few times.
417
+ const MAX_UNKNOWN_RETRIES = 3;
418
+ const UNKNOWN_RETRY_DELAY_MS = 5000;
419
+
420
+ for (let attempt = 0; attempt < MAX_UNKNOWN_RETRIES; attempt++) {
421
+ try {
422
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
423
+ const pr = JSON.parse(stdout.trim());
424
+
425
+ // Issue #1339: If mergeStateStatus is 'UNKNOWN', GitHub is still computing.
426
+ // Wait and retry instead of immediately skipping the PR.
427
+ if (pr.mergeStateStatus === 'UNKNOWN' || pr.mergeable === null) {
428
+ if (attempt < MAX_UNKNOWN_RETRIES - 1) {
429
+ if (verbose) {
430
+ console.log(`[VERBOSE] /merge: PR #${prNumber} mergeability is UNKNOWN (attempt ${attempt + 1}/${MAX_UNKNOWN_RETRIES}), retrying in ${UNKNOWN_RETRY_DELAY_MS / 1000}s...`);
431
+ }
432
+ await new Promise(resolve => setTimeout(resolve, UNKNOWN_RETRY_DELAY_MS));
433
+ continue;
434
+ }
435
+ // All retries exhausted, still UNKNOWN - treat as not mergeable
436
+ if (verbose) {
437
+ console.log(`[VERBOSE] /merge: PR #${prNumber} mergeability still UNKNOWN after ${MAX_UNKNOWN_RETRIES} attempts`);
438
+ }
439
+ return { mergeable: false, reason: `Merge state: UNKNOWN (GitHub could not compute mergeability after ${MAX_UNKNOWN_RETRIES} attempts)` };
436
440
  }
437
- }
438
441
 
439
- if (verbose) {
440
- console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${mergeable}, state: ${pr.mergeStateStatus}`);
441
- }
442
+ const mergeable = pr.mergeable === 'MERGEABLE';
443
+ let reason = null;
444
+
445
+ if (!mergeable) {
446
+ switch (pr.mergeStateStatus) {
447
+ case 'BLOCKED':
448
+ reason = 'PR is blocked (possibly by branch protection rules)';
449
+ break;
450
+ case 'BEHIND':
451
+ reason = 'PR branch is behind the base branch';
452
+ break;
453
+ case 'DIRTY':
454
+ reason = 'PR has merge conflicts';
455
+ break;
456
+ case 'UNSTABLE':
457
+ reason = 'PR has failing required status checks';
458
+ break;
459
+ case 'DRAFT':
460
+ reason = 'PR is a draft';
461
+ break;
462
+ default:
463
+ reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
464
+ }
465
+ }
442
466
 
443
- return { mergeable, reason };
444
- } catch (error) {
445
- if (verbose) {
446
- console.log(`[VERBOSE] /merge: Error checking mergeability: ${error.message}`);
467
+ if (verbose) {
468
+ console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${mergeable}, state: ${pr.mergeStateStatus}`);
469
+ }
470
+
471
+ return { mergeable, reason };
472
+ } catch (error) {
473
+ if (verbose) {
474
+ console.log(`[VERBOSE] /merge: Error checking mergeability: ${error.message}`);
475
+ }
476
+ return { mergeable: false, reason: error.message };
447
477
  }
448
- return { mergeable: false, reason: error.message };
449
478
  }
479
+
480
+ // Should not reach here, but return safe default
481
+ return { mergeable: false, reason: 'Merge state: UNKNOWN' };
450
482
  }
451
483
 
452
484
  /**
@@ -43,6 +43,37 @@ const { sanitizeLogContent, attachLogToGitHub } = githubLib;
43
43
  const restartShared = await import('./solve.restart-shared.lib.mjs');
44
44
  const { checkPRMerged, checkPRClosed, checkForUncommittedChanges, getUncommittedChangesDetails, executeToolIteration, buildAutoRestartInstructions, isApiError } = restartShared;
45
45
 
46
+ /**
47
+ * Issue #1323: Check if a comment with specific content already exists on the PR
48
+ * This prevents duplicate status comments when multiple processes or restarts occur
49
+ * @param {string} owner - Repository owner
50
+ * @param {string} repo - Repository name
51
+ * @param {number} prNumber - Pull request number
52
+ * @param {string} commentSignature - Unique signature to search for in comment body (e.g., "✅ Ready to merge")
53
+ * @param {boolean} verbose - Enable verbose logging
54
+ * @returns {Promise<boolean>} - True if a matching comment already exists
55
+ */
56
+ const checkForExistingComment = async (owner, repo, prNumber, commentSignature, verbose = false) => {
57
+ try {
58
+ // Fetch recent PR comments (last 20 to avoid fetching entire history)
59
+ const result = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --jq '.[].body' 2>/dev/null`;
60
+ if (result.code === 0 && result.stdout) {
61
+ const bodies = result.stdout.toString();
62
+ const hasMatch = bodies.includes(commentSignature);
63
+ if (verbose && hasMatch) {
64
+ console.log(`[VERBOSE] Found existing comment with signature: "${commentSignature}"`);
65
+ }
66
+ return hasMatch;
67
+ }
68
+ } catch (error) {
69
+ // If check fails, allow posting to avoid silent failures
70
+ if (verbose) {
71
+ console.log(`[VERBOSE] Failed to check for existing comment: ${error.message}`);
72
+ }
73
+ }
74
+ return false;
75
+ };
76
+
46
77
  /**
47
78
  * Check for new comments from non-bot users since last commit
48
79
  * @returns {Promise<{hasNewComments: boolean, comments: Array}>}
@@ -258,6 +289,11 @@ export const watchUntilMergeable = async params => {
258
289
  let latestSessionId = null;
259
290
  let latestAnthropicCost = null;
260
291
 
292
+ // Issue #1323: Track actual restart count separately from check cycle iteration
293
+ // `iteration` counts check cycles (how many times we check for blockers)
294
+ // `restartCount` counts actual AI tool executions (when we actually restart the AI)
295
+ let restartCount = 0;
296
+
261
297
  // Track consecutive API errors for retry limit
262
298
  const MAX_API_ERROR_RETRIES = 3;
263
299
  let consecutiveApiErrors = 0;
@@ -344,10 +380,17 @@ export const watchUntilMergeable = async params => {
344
380
  await log(formatAligned('', 'PR is ready to be merged manually', '', 2));
345
381
  await log(formatAligned('', 'Exiting auto-restart-until-mergeable mode', '', 2));
346
382
 
347
- // Post success comment
383
+ // Issue #1323: Post success comment only if one doesn't already exist
384
+ // This prevents duplicate comments when multiple processes reach this point
348
385
  try {
349
- const commentBody = `## ✅ Ready to merge\n\nThis pull request is now ready to be merged:\n- All CI checks have passed\n- No merge conflicts\n- No pending changes\n\n---\n*Monitored by hive-mind with --auto-restart-until-mergeable flag*`;
350
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
386
+ const readyToMergeSignature = '## ✅ Ready to merge';
387
+ const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
388
+ if (!hasExistingComment) {
389
+ const commentBody = `## ✅ Ready to merge\n\nThis pull request is now ready to be merged:\n- All CI checks have passed\n- No merge conflicts\n- No pending changes\n\n---\n*Monitored by hive-mind with --auto-restart-until-mergeable flag*`;
390
+ await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
391
+ } else {
392
+ await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment', '', 2));
393
+ }
351
394
  } catch {
352
395
  // Don't fail if comment posting fails
353
396
  }
@@ -518,10 +561,14 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
518
561
  }
519
562
 
520
563
  if (shouldRestart) {
564
+ // Issue #1323: Increment restart count (actual AI executions, not check cycles)
565
+ restartCount++;
566
+
521
567
  // Add standard instructions for auto-restart-until-mergeable mode using shared utility
522
568
  feedbackLines.push(...buildAutoRestartInstructions());
523
569
 
524
570
  await log(formatAligned('🔄', 'RESTART TRIGGERED:', restartReason));
571
+ await log(formatAligned('', 'Restart iteration:', `${restartCount}`, 2));
525
572
  await log('');
526
573
 
527
574
  // Post a comment to PR about the restart
@@ -601,7 +648,8 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
601
648
  try {
602
649
  const logFile = getLogFile();
603
650
  if (logFile) {
604
- const customTitle = `🔄 Auto-restart-until-mergeable Log (iteration ${iteration})`;
651
+ // Issue #1323: Use restartCount (actual AI executions) instead of iteration (check cycles)
652
+ const customTitle = `🔄 Auto-restart-until-mergeable Log (iteration ${restartCount})`;
605
653
  await attachLogToGitHub({
606
654
  logFile,
607
655
  targetType: 'pr',
@@ -779,11 +827,17 @@ export const startAutoRestartUntilMergeable = async params => {
779
827
  await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
780
828
  await log('');
781
829
 
782
- // Post a comment to the PR notifying the maintainer
830
+ // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
783
831
  try {
784
- const commentBody = `## ✅ Ready to merge\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because this PR was created from a fork (no write access to the target repository).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag (fork mode)*`;
785
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
786
- await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
832
+ const readyToMergeSignature = '## ✅ Ready to merge';
833
+ const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
834
+ if (!hasExistingComment) {
835
+ const commentBody = `## ✅ Ready to merge\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because this PR was created from a fork (no write access to the target repository).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag (fork mode)*`;
836
+ await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
837
+ await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
838
+ } else {
839
+ await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment', '', 2));
840
+ }
787
841
  } catch {
788
842
  // Don't fail if comment posting fails
789
843
  }
@@ -802,11 +856,17 @@ export const startAutoRestartUntilMergeable = async params => {
802
856
  await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
803
857
  await log('');
804
858
 
805
- // Post a comment to the PR notifying the maintainer
859
+ // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
806
860
  try {
807
- const commentBody = `## ✅ Ready to merge\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag*`;
808
- await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
809
- await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
861
+ const readyToMergeSignature = '## ✅ Ready to merge';
862
+ const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
863
+ if (!hasExistingComment) {
864
+ const commentBody = `## ✅ Ready to merge\n\nThis pull request is ready to be merged. Auto-merge was requested (\`--auto-merge\`) but cannot be performed because the authenticated user lacks write access to \`${owner}/${repo}\` (current permission: \`${permission || 'unknown'}\`).\n\nPlease merge manually.\n\n---\n*hive-mind with --auto-merge flag*`;
865
+ await $`gh pr comment ${prNumber} --repo ${owner}/${repo} --body ${commentBody}`;
866
+ await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
867
+ } else {
868
+ await log(formatAligned('', 'Skipping duplicate "Ready to merge" comment', '', 2));
869
+ }
810
870
  } catch {
811
871
  // Don't fail if comment posting fails
812
872
  }
@@ -520,7 +520,7 @@ export class MergeQueueProcessor {
520
520
  const elapsedSec = Math.round(this.targetBranchCIStatus.elapsedMs / 1000);
521
521
  const elapsedMin = Math.floor(elapsedSec / 60);
522
522
  const elapsedSecRemainder = elapsedSec % 60;
523
- message += `⏱️ Waiting for ${this.targetBranchCIStatus.count} CI run\\(s\\) on target branch to complete \\(${elapsedMin}m ${elapsedSecRemainder}s\\)\\.\\.\\.\\n\\n`;
523
+ message += `⏱️ Waiting for ${this.targetBranchCIStatus.count} CI run\\(s\\) on target branch to complete \\(${elapsedMin}m ${elapsedSecRemainder}s\\)\\.\\.\\.\n\n`;
524
524
  } else if (this.waitingForTargetBranchCI) {
525
525
  message += `⏱️ Checking for active CI runs on target branch\\.\\.\\.\n\n`;
526
526
  }
@@ -528,7 +528,8 @@ export class MergeQueueProcessor {
528
528
  // Current item being processed
529
529
  if (update.current && !this.waitingForTargetBranchCI) {
530
530
  const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
531
- message += `${statusEmoji} ${update.current}\n\n`;
531
+ // Issue #1339: escape the current item description for MarkdownV2
532
+ message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
532
533
  }
533
534
 
534
535
  // Show errors/failures/skips inline so user gets immediate feedback (Issue #1269, #1294)
@@ -538,10 +539,12 @@ export class MergeQueueProcessor {
538
539
  message += `⚠️ *Issues:*\n`;
539
540
  for (const item of problemItems.slice(0, 5)) {
540
541
  const statusEmoji = item.status === MergeItemStatus.FAILED ? '❌' : '⏭️';
541
- message += ` ${statusEmoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '...' : ''}\n`;
542
+ // Issue #1339: escape the ellipsis '...' for MarkdownV2 (periods are reserved)
543
+ message += ` ${statusEmoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '\\.\\.\\.' : ''}\n`;
542
544
  }
543
545
  if (problemItems.length > 5) {
544
- message += ` _...and ${problemItems.length - 5} more issues_\n`;
546
+ // Issue #1339: escape the ellipsis '...' for MarkdownV2
547
+ message += ` _\\.\\.\\.and ${problemItems.length - 5} more issues_\n`;
545
548
  }
546
549
  message += '\n';
547
550
  }
@@ -549,11 +552,13 @@ export class MergeQueueProcessor {
549
552
  // PRs list with emojis
550
553
  message += `*Queue:*\n`;
551
554
  for (const item of update.items.slice(0, 10)) {
552
- message += `${item.emoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.title.substring(0, 35))}${item.title.length > 35 ? '...' : ''}\n`;
555
+ // Issue #1339: escape the ellipsis '...' for MarkdownV2 (periods are reserved)
556
+ message += `${item.emoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.title.substring(0, 35))}${item.title.length > 35 ? '\\.\\.\\.' : ''}\n`;
553
557
  }
554
558
 
555
559
  if (update.items.length > 10) {
556
- message += `_...and ${update.items.length - 10} more_\n`;
560
+ // Issue #1339: escape the ellipsis '...' for MarkdownV2
561
+ message += `_\\.\\.\\.and ${update.items.length - 10} more_\n`;
557
562
  }
558
563
 
559
564
  return message;