@link-assistant/hive-mind 1.24.1 → 1.24.3

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,22 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.24.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 297e07c: Fix incorrect iteration counter and duplicate comments in auto-restart mode
8
+ - Fixed iteration counter to show actual AI restart count instead of check cycle number
9
+ - Added deduplication check to prevent duplicate "Ready to merge" status comments
10
+ - Added case study documentation for issue #1323
11
+
12
+ ## 1.24.2
13
+
14
+ ### Patch Changes
15
+
16
+ - a74e10c: fix: add auto-resume with session preservation on Internal Server Error (Issue #1331)
17
+
18
+ When Claude tool returns `API Error: 500 Internal server error`, automatically retry with exponential backoff starting from 1 minute, capped at 30 minutes per retry, up to 10 retries. Session ID is preserved so Claude Code can resume from where it left off using `--resume <sessionId>`.
19
+
3
20
  ## 1.24.1
4
21
 
5
22
  ### 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.1",
3
+ "version": "1.24.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -747,17 +747,30 @@ export const executeClaudeCommand = async params => {
747
747
  repo,
748
748
  prNumber,
749
749
  } = params;
750
- // Retry configuration for API overload errors
751
- const maxRetries = 3;
752
- const baseDelay = timeouts.retryBaseDelay;
750
+ // Issue #1331: Unified retry configuration for all transient API errors
751
+ // (Overloaded, 503 Network Error, Internal Server Error) - same params, all with session preservation
753
752
  let retryCount = 0;
753
+ // Helper: wait with per-minute countdown for delays >1 minute (Issue #1331)
754
+ const waitWithCountdown = async (delayMs, log) => {
755
+ if (delayMs <= 60000) {
756
+ await new Promise(resolve => setTimeout(resolve, delayMs));
757
+ return;
758
+ }
759
+ let remaining = delayMs;
760
+ const timer = setInterval(async () => {
761
+ remaining -= 60000;
762
+ if (remaining > 0) await log(`⏳ ${Math.round(remaining / 60000)} min remaining...`);
763
+ }, 60000);
764
+ await new Promise(resolve => setTimeout(resolve, delayMs));
765
+ clearInterval(timer);
766
+ };
754
767
  // Function to execute with retry logic
755
768
  const executeWithRetry = async () => {
756
769
  // Execute claude command from the cloned repository directory
757
770
  if (retryCount === 0) {
758
771
  await log(`\n${formatAligned('🤖', 'Executing Claude:', argv.model.toUpperCase())}`);
759
772
  } else {
760
- await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}/${maxRetries}`)}`);
773
+ await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
761
774
  }
762
775
  if (argv.verbose) {
763
776
  // Output the actual model being used
@@ -789,6 +802,7 @@ export const executeClaudeCommand = async params => {
789
802
  let lastMessage = '';
790
803
  let isOverloadError = false;
791
804
  let is503Error = false;
805
+ let isInternalServerError = false; // Issue #1331: Track 500 Internal server error
792
806
  let stderrErrors = [];
793
807
  let anthropicTotalCostUSD = null; // Capture Anthropic's official total_cost_usd from result
794
808
  let errorDuringExecution = false; // Issue #1088: Track if error_during_execution subtype occurred
@@ -979,6 +993,9 @@ export const executeClaudeCommand = async params => {
979
993
  limitReached = true;
980
994
  await log('⚠️ Detected session limit in result', { verbose: true });
981
995
  }
996
+ if (lastMessage.includes('Internal server error') && !lastMessage.includes('Overloaded')) {
997
+ isInternalServerError = true;
998
+ }
982
999
  }
983
1000
  }
984
1001
  // Store last message for error detection
@@ -986,6 +1003,9 @@ export const executeClaudeCommand = async params => {
986
1003
  lastMessage = data.text;
987
1004
  } else if (data.type === 'error') {
988
1005
  lastMessage = data.error || JSON.stringify(data);
1006
+ if (lastMessage.includes('Internal server error')) {
1007
+ isInternalServerError = true;
1008
+ }
989
1009
  }
990
1010
  // Check for API overload error and 503 errors
991
1011
  if (data.type === 'assistant' && data.message && data.message.content) {
@@ -998,6 +1018,10 @@ export const executeClaudeCommand = async params => {
998
1018
  lastMessage = item.text;
999
1019
  await log('⚠️ Detected API overload error', { verbose: true });
1000
1020
  }
1021
+ if (item.text.includes('API Error: 500') && item.text.includes('Internal server error') && !item.text.includes('Overloaded')) {
1022
+ isInternalServerError = true;
1023
+ lastMessage = item.text;
1024
+ }
1001
1025
  // Check for 503 errors
1002
1026
  if (item.text.includes('API Error: 503') || (item.text.includes('503') && item.text.includes('upstream connect error')) || (item.text.includes('503') && item.text.includes('remote connection failure'))) {
1003
1027
  is503Error = true;
@@ -1110,64 +1134,22 @@ export const executeClaudeCommand = async params => {
1110
1134
  }
1111
1135
  }
1112
1136
 
1113
- if ((commandFailed || isOverloadError) && (isOverloadError || (lastMessage.includes('API Error: 500') && lastMessage.includes('Overloaded')) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')))) {
1114
- if (retryCount < maxRetries) {
1115
- // Calculate exponential backoff delay
1116
- const delay = baseDelay * Math.pow(2, retryCount);
1117
- await log(`\n⚠️ API overload error detected. Retrying in ${delay / 1000} seconds...`, { level: 'warning' });
1118
- await log(` Error: ${lastMessage.substring(0, 200)}`, { verbose: true });
1119
- // Wait before retrying
1120
- await new Promise(resolve => setTimeout(resolve, delay));
1121
- // Increment retry count and retry
1122
- retryCount++;
1123
- return await executeWithRetry();
1124
- } else {
1125
- await log(`\n\n❌ API overload error persisted after ${maxRetries} retries\n The API appears to be heavily loaded. Please try again later.`, { level: 'error' });
1126
- return {
1127
- success: false,
1128
- sessionId,
1129
- limitReached: false,
1130
- limitResetTime: null,
1131
- limitTimezone: null,
1132
- messageCount,
1133
- toolUseCount,
1134
- anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1135
- resultSummary, // Issue #1263: Include result summary
1136
- };
1137
- }
1138
- }
1139
- if ((commandFailed || is503Error) && argv.autoResumeOnErrors && (is503Error || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && lastMessage.includes('upstream connect error')) || (lastMessage.includes('503') && lastMessage.includes('remote connection failure')))) {
1140
- if (retryCount < retryLimits.max503Retries) {
1141
- // Calculate exponential backoff delay starting from 5 minutes
1142
- const delay = retryLimits.initial503RetryDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount);
1143
- const delayMinutes = Math.round(delay / (1000 * 60));
1144
- await log(`\n⚠️ 503 network error detected. Retrying in ${delayMinutes} minutes...`, { level: 'warning' });
1137
+ // Issue #1331: Unified handler for all transient API errors (Overloaded, 503, Internal Server Error)
1138
+ // All use same params: 10 retries, 1min initial, 30min max, exponential backoff, session preserved
1139
+ const isTransientError = isOverloadError || isInternalServerError || is503Error || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure')));
1140
+ if ((commandFailed || isTransientError) && isTransientError) {
1141
+ if (retryCount < retryLimits.maxTransientErrorRetries) {
1142
+ const delay = Math.min(retryLimits.initialTransientErrorDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount), retryLimits.maxTransientErrorDelayMs);
1143
+ const errorLabel = isOverloadError || (lastMessage.includes('API Error: 500') && lastMessage.includes('Overloaded')) ? 'API overload (500)' : isInternalServerError || lastMessage.includes('Internal server error') ? 'Internal server error (500)' : '503 network error';
1144
+ await log(`\n⚠️ ${errorLabel} detected. Retry ${retryCount + 1}/${retryLimits.maxTransientErrorRetries} in ${Math.round(delay / 60000)} min (session preserved)...`, { level: 'warning' });
1145
1145
  await log(` Error: ${lastMessage.substring(0, 200)}`, { verbose: true });
1146
- await log(` Retry ${retryCount + 1}/${retryLimits.max503Retries}`, { verbose: true });
1147
- // Show countdown for long waits
1148
- if (delay > 60000) {
1149
- const countdownInterval = 60000; // Every minute
1150
- let remainingMs = delay;
1151
- const countdownTimer = setInterval(async () => {
1152
- remainingMs -= countdownInterval;
1153
- if (remainingMs > 0) {
1154
- const remainingMinutes = Math.round(remainingMs / (1000 * 60));
1155
- await log(`⏳ ${remainingMinutes} minutes remaining until retry...`);
1156
- }
1157
- }, countdownInterval);
1158
- // Wait before retrying
1159
- await new Promise(resolve => setTimeout(resolve, delay));
1160
- clearInterval(countdownTimer);
1161
- } else {
1162
- // Wait before retrying
1163
- await new Promise(resolve => setTimeout(resolve, delay));
1164
- }
1146
+ if (sessionId && !argv.resume) argv.resume = sessionId; // preserve session for resume
1147
+ await waitWithCountdown(delay, log);
1165
1148
  await log('\n🔄 Retrying now...');
1166
- // Increment retry count and retry
1167
1149
  retryCount++;
1168
1150
  return await executeWithRetry();
1169
1151
  } else {
1170
- await log(`\n\n❌ 503 network error persisted after ${retryLimits.max503Retries} retries\n The Anthropic API appears to be experiencing network issues.\n Please try again later or check https://status.anthropic.com/`, { level: 'error' });
1152
+ await log(`\n\n❌ Transient API error persisted after ${retryLimits.maxTransientErrorRetries} retries\n Please try again later or check https://status.anthropic.com/`, { level: 'error' });
1171
1153
  return {
1172
1154
  success: false,
1173
1155
  sessionId,
@@ -1176,7 +1158,7 @@ export const executeClaudeCommand = async params => {
1176
1158
  limitTimezone: null,
1177
1159
  messageCount,
1178
1160
  toolUseCount,
1179
- is503Error: true,
1161
+ is503Error, // preserve for callers that check this
1180
1162
  anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1181
1163
  resultSummary, // Issue #1263: Include result summary
1182
1164
  };
@@ -1338,31 +1320,17 @@ export const executeClaudeCommand = async params => {
1338
1320
  operation: 'run_claude_command',
1339
1321
  });
1340
1322
  const errorStr = error.message || error.toString();
1341
- if ((errorStr.includes('API Error: 500') && errorStr.includes('Overloaded')) || (errorStr.includes('api_error') && errorStr.includes('Overloaded'))) {
1342
- if (retryCount < maxRetries) {
1343
- // Calculate exponential backoff delay
1344
- const delay = baseDelay * Math.pow(2, retryCount);
1345
- await log(`\n⚠️ API overload error in exception. Retrying in ${delay / 1000} seconds...`, {
1346
- level: 'warning',
1347
- });
1348
- // Wait before retrying
1349
- await new Promise(resolve => setTimeout(resolve, delay));
1350
- // Increment retry count and retry
1351
- retryCount++;
1352
- return await executeWithRetry();
1353
- }
1354
- }
1355
- if (argv.autoResumeOnErrors && (errorStr.includes('API Error: 503') || (errorStr.includes('503') && errorStr.includes('upstream connect error')) || (errorStr.includes('503') && errorStr.includes('remote connection failure')))) {
1356
- if (retryCount < retryLimits.max503Retries) {
1357
- // Calculate exponential backoff delay starting from 5 minutes
1358
- const delay = retryLimits.initial503RetryDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount);
1359
- const delayMinutes = Math.round(delay / (1000 * 60));
1360
- await log(`\n⚠️ 503 network error in exception. Retrying in ${delayMinutes} minutes...`, {
1361
- level: 'warning',
1362
- });
1363
- // Wait before retrying
1364
- await new Promise(resolve => setTimeout(resolve, delay));
1365
- // Increment retry count and retry
1323
+ // Issue #1331: Unified handler for all transient API errors in exception block
1324
+ // (Overloaded, 503, Internal Server Error) - same params, all with session preservation
1325
+ const isTransientException = (errorStr.includes('API Error: 500') && (errorStr.includes('Overloaded') || errorStr.includes('Internal server error'))) || (errorStr.includes('api_error') && errorStr.includes('Overloaded')) || errorStr.includes('API Error: 503') || (errorStr.includes('503') && (errorStr.includes('upstream connect error') || errorStr.includes('remote connection failure')));
1326
+ if (isTransientException) {
1327
+ if (retryCount < retryLimits.maxTransientErrorRetries) {
1328
+ const delay = Math.min(retryLimits.initialTransientErrorDelayMs * Math.pow(retryLimits.retryBackoffMultiplier, retryCount), retryLimits.maxTransientErrorDelayMs);
1329
+ const errorLabel = errorStr.includes('Overloaded') ? 'API overload (500)' : errorStr.includes('Internal server error') ? 'Internal server error (500)' : '503 network error';
1330
+ await log(`\n⚠️ ${errorLabel} in exception. Retry ${retryCount + 1}/${retryLimits.maxTransientErrorRetries} in ${Math.round(delay / 60000)} min (session preserved)...`, { level: 'warning' });
1331
+ if (sessionId && !argv.resume) argv.resume = sessionId;
1332
+ await waitWithCountdown(delay, log);
1333
+ await log('\n🔄 Retrying now...');
1366
1334
  retryCount++;
1367
1335
  return await executeWithRetry();
1368
1336
  }
@@ -92,13 +92,17 @@ export const systemLimits = {
92
92
  };
93
93
 
94
94
  // Retry configurations
95
+ // Issue #1331: All API error types use unified retry parameters:
96
+ // 10 max retries, 1 minute initial delay, 30 minute max delay (exponential backoff), session preserved
95
97
  export const retryLimits = {
96
98
  maxForkRetries: parseIntWithDefault('HIVE_MIND_MAX_FORK_RETRIES', 5),
97
99
  maxVerifyRetries: parseIntWithDefault('HIVE_MIND_MAX_VERIFY_RETRIES', 5),
98
100
  maxApiRetries: parseIntWithDefault('HIVE_MIND_MAX_API_RETRIES', 3),
99
101
  retryBackoffMultiplier: parseFloatWithDefault('HIVE_MIND_RETRY_BACKOFF_MULTIPLIER', 2),
100
- max503Retries: parseIntWithDefault('HIVE_MIND_MAX_503_RETRIES', 3),
101
- initial503RetryDelayMs: parseIntWithDefault('HIVE_MIND_INITIAL_503_RETRY_DELAY_MS', 5 * 60 * 1000), // 5 minutes
102
+ // Unified retry config for all transient API errors (Overloaded, 503, Internal Server Error)
103
+ maxTransientErrorRetries: parseIntWithDefault('HIVE_MIND_MAX_TRANSIENT_ERROR_RETRIES', 10),
104
+ initialTransientErrorDelayMs: parseIntWithDefault('HIVE_MIND_INITIAL_TRANSIENT_ERROR_DELAY_MS', 60 * 1000), // 1 minute
105
+ maxTransientErrorDelayMs: parseIntWithDefault('HIVE_MIND_MAX_TRANSIENT_ERROR_DELAY_MS', 30 * 60 * 1000), // 30 minutes
102
106
  };
103
107
 
104
108
  // Claude Code CLI configurations
@@ -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
  }