@link-assistant/hive-mind 1.65.0 → 1.65.2

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,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.65.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 0214c9e: Retry transient 5xx/network errors across all `gh` exec sites. Previously a single 504 from the GitHub GraphQL endpoint could abort `solve` during `gh pr create`. The retry helper now handles HTTP 502/503/504, socket hang up, ECONNRESET, ETIMEDOUT, and TLS handshake timeouts in addition to rate-limit errors, with a separate retry budget and exponential backoff. All direct `execAsync('gh ...')` sites are routed through `execGhWithRetry`.
8
+
9
+ ## 1.65.1
10
+
11
+ ### Patch Changes
12
+
13
+ - d5cd096: Add a solve flag to disable separate error-report issue creation while preserving original issue failure comments, and improve pre-PR branch divergence diagnostics.
14
+
3
15
  ## 1.65.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.65.0",
3
+ "version": "1.65.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -271,7 +271,7 @@ export const handleErrorWithIssueCreation = async options => {
271
271
 
272
272
  // --disable-report-issue takes highest precedence
273
273
  if (disableReport) {
274
- await log('ℹ️ Issue reporting disabled via --disable-report-issue.');
274
+ await log('ℹ️ Error issue creation is disabled by CLI configuration.');
275
275
  return null;
276
276
  }
277
277
 
@@ -169,46 +169,98 @@ const sleepWithCountdown = async (ms, log) => {
169
169
  }
170
170
  };
171
171
 
172
+ /**
173
+ * Patterns matched against an error's combined message/stderr/stdout to decide
174
+ * whether the failure is a transient network/edge fault that deserves a retry.
175
+ * Mirrors `isTransientNetworkError` in `src/lib.mjs` (issue #1536); duplicated
176
+ * here to avoid a circular import — `lib.mjs` already imports from this file.
177
+ *
178
+ * Issue #1756: `gh pr create` failed with `HTTP 504: 504 Gateway Timeout
179
+ * (https://api.github.com/graphql)`. `execGhWithRetry`/`ghWithRateLimitRetry`
180
+ * only handled rate-limit errors before — a single 504 was fatal.
181
+ */
182
+ const TRANSIENT_NETWORK_PATTERNS = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up', 'unexpected eof'];
183
+
184
+ const isTransientNetworkError = error => {
185
+ const text = collectErrorText(error).toLowerCase();
186
+ if (!text) return false;
187
+ return TRANSIENT_NETWORK_PATTERNS.some(pattern => text.includes(pattern));
188
+ };
189
+
172
190
  /**
173
191
  * Wrap `fn` so that GitHub rate-limit errors are converted into a sleep until
174
- * (resetTime + bufferMs + jitterMs) followed by a retry. Non-rate-limit errors
175
- * are rethrown immediately so we don't mask programming bugs or 404s.
192
+ * (resetTime + bufferMs + jitterMs) followed by a retry. Transient network
193
+ * errors (504/502/503, socket hang up, TLS timeouts) get exponential backoff
194
+ * and a separate retry budget. Other errors are rethrown immediately so we
195
+ * don't mask programming bugs or 404s.
196
+ *
197
+ * Issue #1726 — rate-limit retry. Issue #1756 — transient network retry.
176
198
  *
177
199
  * @template T
178
200
  * @param {() => Promise<T>} fn
179
201
  * @param {object} [options]
180
202
  * @param {number} [options.maxAttempts] - hard cap on rate-limit retries (default `retryLimits.maxApiRetries`).
203
+ * @param {number} [options.transientMaxAttempts] - hard cap on transient network retries (default `retryLimits.maxApiRetries`).
204
+ * @param {number} [options.transientDelay] - initial transient retry delay in ms (default 1000).
205
+ * @param {number} [options.transientBackoff] - backoff multiplier for transient retries (default 2).
181
206
  * @param {string} [options.label] - prefix for log messages.
182
207
  * @param {(msg: string) => Promise<void>|void} [options.log] - logger. Defaults to console.warn.
183
208
  * @returns {Promise<T>}
184
209
  */
185
210
  export const ghWithRateLimitRetry = async (fn, options = {}) => {
186
211
  const maxAttempts = options.maxAttempts ?? retryLimits.maxApiRetries;
212
+ const transientMaxAttempts = options.transientMaxAttempts ?? retryLimits.maxApiRetries;
213
+ const transientDelay = options.transientDelay ?? 1000;
214
+ const transientBackoff = options.transientBackoff ?? 2;
187
215
  const label = options.label || 'gh';
188
216
  const log = options.log || (msg => console.warn(msg));
189
217
 
218
+ // Two independent retry budgets — a long string of rate-limit responses
219
+ // shouldn't burn the transient-error retries, and vice versa.
220
+ let rateLimitAttempts = 0;
221
+ let transientAttempts = 0;
190
222
  let lastError;
191
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
223
+ // Hard cap so a permanently broken endpoint can't loop forever — sum of
224
+ // both budgets plus a safety margin.
225
+ const hardCap = maxAttempts + transientMaxAttempts + 1;
226
+
227
+ for (let i = 0; i < hardCap; i++) {
192
228
  try {
193
229
  return await fn();
194
230
  } catch (error) {
195
231
  lastError = error;
196
- if (!isRateLimitError(error)) throw error;
197
232
 
198
- if (attempt === maxAttempts) {
199
- await Promise.resolve(log(`❌ ${label}: rate limit still active after ${attempt} attempts; giving up.`));
200
- throw error;
233
+ if (isRateLimitError(error)) {
234
+ rateLimitAttempts++;
235
+ if (rateLimitAttempts >= maxAttempts) {
236
+ await Promise.resolve(log(`❌ ${label}: rate limit still active after ${rateLimitAttempts} attempts; giving up.`));
237
+ throw error;
238
+ }
239
+ const reset = parseRateLimitReset(error) || (await fetchNextRateLimitReset());
240
+ const { waitMs, deadline, bufferMs, jitterMs } = computeRateLimitWait(reset);
241
+ const waitMinutes = Math.round(waitMs / 60_000);
242
+ const resetSummary = reset ? `reset at ${reset.toISOString()}` : 'reset time unknown (using buffer + jitter only)';
243
+ await Promise.resolve(log(`⏳ ${label}: GitHub API rate limit hit (attempt ${rateLimitAttempts}/${maxAttempts}). Waiting ${waitMinutes} min (${resetSummary}; buffer ${Math.round(bufferMs / 60_000)} min + jitter ${Math.round(jitterMs / 1000)}s) until ${deadline.toISOString()}.`));
244
+ await sleepWithCountdown(waitMs, log);
245
+ continue;
201
246
  }
202
247
 
203
- const reset = parseRateLimitReset(error) || (await fetchNextRateLimitReset());
204
- const { waitMs, deadline, bufferMs, jitterMs } = computeRateLimitWait(reset);
205
- const waitMinutes = Math.round(waitMs / 60_000);
206
- const resetSummary = reset ? `reset at ${reset.toISOString()}` : 'reset time unknown (using buffer + jitter only)';
207
- await Promise.resolve(log(`⏳ ${label}: GitHub API rate limit hit (attempt ${attempt}/${maxAttempts}). Waiting ${waitMinutes} min (${resetSummary}; buffer ${Math.round(bufferMs / 60_000)} min + jitter ${Math.round(jitterMs / 1000)}s) until ${deadline.toISOString()}.`));
208
- await sleepWithCountdown(waitMs, log);
248
+ if (isTransientNetworkError(error)) {
249
+ transientAttempts++;
250
+ if (transientAttempts >= transientMaxAttempts) {
251
+ await Promise.resolve(log(`❌ ${label}: transient network error persisted after ${transientAttempts} attempts; giving up.`));
252
+ throw error;
253
+ }
254
+ const waitMs = transientDelay * Math.pow(transientBackoff, transientAttempts - 1);
255
+ await Promise.resolve(log(`⚠️ ${label}: transient network error (attempt ${transientAttempts}/${transientMaxAttempts}), retrying in ${Math.round(waitMs / 1000)}s...`));
256
+ await sleepWithCountdown(waitMs, log);
257
+ continue;
258
+ }
259
+
260
+ throw error;
209
261
  }
210
262
  }
211
- // Unreachable — loop either returns or throws.
263
+ // Unreachable — loop either returns or throws via the budgets above.
212
264
  throw lastError;
213
265
  };
214
266
 
@@ -265,8 +317,11 @@ export const wrapDollarWithGhRetry = (dollar, options = {}) => {
265
317
  return wrapped;
266
318
  };
267
319
 
320
+ export { isTransientNetworkError };
321
+
268
322
  export default {
269
323
  isRateLimitError,
324
+ isTransientNetworkError,
270
325
  parseRateLimitReset,
271
326
  fetchNextRateLimitReset,
272
327
  computeRateLimitWait,
@@ -11,7 +11,7 @@ if (typeof globalThis.use === 'undefined') {
11
11
  import { log, cleanErrorMessage } from './lib.mjs';
12
12
  import { githubLimits, timeouts } from './config.lib.mjs';
13
13
 
14
- import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
14
+ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
15
15
  /**
16
16
  * Check if a PR body/title indicates it fixes/closes/resolves a specific issue number
17
17
  * GitHub auto-closes issues when PR body contains keywords like "fixes #123", "closes #123", "resolves #123"
@@ -124,14 +124,14 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
124
124
  await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay));
125
125
  }
126
126
 
127
- // Execute GraphQL query
128
- const { exec } = await import('child_process');
129
- const { promisify } = await import('util');
130
- const execAsync = promisify(exec);
131
- const { stdout } = await execAsync(`gh api graphql -f query='${query}'`, {
132
- encoding: 'utf8',
133
- maxBuffer: githubLimits.bufferMaxSize,
134
- env: process.env,
127
+ // Execute GraphQL query (#1756: route through execGhWithRetry for transient 5xx + rate-limit)
128
+ const { stdout } = await execGhWithRetry(`gh api graphql -f query='${query}'`, {
129
+ execOptions: {
130
+ encoding: 'utf8',
131
+ maxBuffer: githubLimits.bufferMaxSize,
132
+ env: process.env,
133
+ },
134
+ label: 'gh api graphql (batch PR check)',
135
135
  });
136
136
 
137
137
  const data = JSON.parse(stdout);
@@ -191,12 +191,13 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
191
191
 
192
192
  for (const issueNum of batch) {
193
193
  try {
194
- const { exec } = await import('child_process');
195
- const { promisify } = await import('util');
196
- const execAsync = promisify(exec);
197
194
  const cmd = `gh api repos/${owner}/${repo}/issues/${issueNum}/timeline --paginate --jq '[.[] | select(.event == "cross-referenced" and .source.issue.pull_request != null and .source.issue.state == "open")] | length'`;
198
195
 
199
- const { stdout } = await execAsync(cmd, { encoding: 'utf8', env: process.env });
196
+ // #1756: route REST fallback through execGhWithRetry for transient 5xx + rate-limit
197
+ const { stdout } = await execGhWithRetry(cmd, {
198
+ execOptions: { encoding: 'utf8', env: process.env },
199
+ label: `gh api timeline (issue #${issueNum})`,
200
+ });
200
201
  const openPrCount = parseInt(stdout.trim()) || 0;
201
202
 
202
203
  results[issueNum] = {
@@ -271,14 +272,14 @@ export async function batchCheckArchivedRepositories(repositories) {
271
272
  await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay));
272
273
  }
273
274
 
274
- // Execute GraphQL query
275
- const { exec } = await import('child_process');
276
- const { promisify } = await import('util');
277
- const execAsync = promisify(exec);
278
- const { stdout } = await execAsync(`gh api graphql -f query='${query}'`, {
279
- encoding: 'utf8',
280
- maxBuffer: githubLimits.bufferMaxSize,
281
- env: process.env,
275
+ // Execute GraphQL query (#1756: route through execGhWithRetry for transient 5xx + rate-limit)
276
+ const { stdout } = await execGhWithRetry(`gh api graphql -f query='${query}'`, {
277
+ execOptions: {
278
+ encoding: 'utf8',
279
+ maxBuffer: githubLimits.bufferMaxSize,
280
+ env: process.env,
281
+ },
282
+ label: 'gh api graphql (batch archived check)',
282
283
  });
283
284
 
284
285
  const data = JSON.parse(stdout);
@@ -301,12 +302,13 @@ export async function batchCheckArchivedRepositories(repositories) {
301
302
 
302
303
  for (const repo of batch) {
303
304
  try {
304
- const { exec } = await import('child_process');
305
- const { promisify } = await import('util');
306
- const execAsync = promisify(exec);
307
305
  const cmd = `gh api repos/${repo.owner}/${repo.name} --jq .archived`;
308
306
 
309
- const { stdout } = await execAsync(cmd, { encoding: 'utf8', env: process.env });
307
+ // #1756: route REST fallback through execGhWithRetry for transient 5xx + rate-limit
308
+ const { stdout } = await execGhWithRetry(cmd, {
309
+ execOptions: { encoding: 'utf8', env: process.env },
310
+ label: `gh api repos (${repo.owner}/${repo.name})`,
311
+ });
310
312
  const isArchived = stdout.trim() === 'true';
311
313
 
312
314
  const repoKey = `${repo.owner}/${repo.name}`;
@@ -3,6 +3,8 @@
3
3
  * This module provides functions to fetch issues using GitHub's GraphQL API
4
4
  */
5
5
 
6
+ import { execGhWithRetry } from './github-rate-limit.lib.mjs'; // #1756: route gh exec through transient + rate-limit retry wrapper
7
+
6
8
  /**
7
9
  * Fetch issues from a single repository with pagination support for >100 issues
8
10
  * @param {string} owner - Repository owner
@@ -13,9 +15,6 @@
13
15
  * @returns {Promise<Array>} Array of issues
14
16
  */
15
17
  async function fetchRepositoryIssuesWithPagination(owner, repoName, log, cleanErrorMessage, issueLimit = 100) {
16
- const { exec } = await import('child_process');
17
- const { promisify } = await import('util');
18
- const execAsync = promisify(exec);
19
18
  const allIssues = [];
20
19
  let hasNextPage = true;
21
20
  let cursor = null;
@@ -59,7 +58,10 @@ async function fetchRepositoryIssuesWithPagination(owner, repoName, log, cleanEr
59
58
  // Add delay for rate limiting
60
59
  await new Promise(resolve => setTimeout(resolve, 1000));
61
60
 
62
- const { stdout } = await execAsync(graphqlCmd, { encoding: 'utf8', env: process.env });
61
+ const { stdout } = await execGhWithRetry(graphqlCmd, {
62
+ execOptions: { encoding: 'utf8', env: process.env },
63
+ label: `gh api graphql (issues page ${pageNum} of ${owner}/${repoName})`,
64
+ });
63
65
  const data = JSON.parse(stdout);
64
66
  const issuesData = data.data.repository.issues;
65
67
 
@@ -95,10 +97,6 @@ async function fetchRepositoryIssuesWithPagination(owner, repoName, log, cleanEr
95
97
  * @returns {Promise<{success: boolean, issues: Array, repoCount: number}>}
96
98
  */
97
99
  export async function tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage, repoLimit = 100, issueLimit = 100) {
98
- const { exec } = await import('child_process');
99
- const { promisify } = await import('util');
100
- const execAsync = promisify(exec);
101
-
102
100
  try {
103
101
  await log(' 🧪 Attempting GraphQL approach with pagination support...', { verbose: true });
104
102
 
@@ -174,7 +172,10 @@ export async function tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMes
174
172
  // Add delay for rate limiting
175
173
  await new Promise(resolve => setTimeout(resolve, 2000));
176
174
 
177
- const { stdout } = await execAsync(graphqlCmd, { encoding: 'utf8', env: process.env });
175
+ const { stdout } = await execGhWithRetry(graphqlCmd, {
176
+ execOptions: { encoding: 'utf8', env: process.env },
177
+ label: `gh api graphql (repos page ${repoPageNum} of ${owner})`,
178
+ });
178
179
  const data = JSON.parse(stdout);
179
180
  const repos = isOrg ? data.data.organization.repositories : data.data.user.repositories;
180
181
 
@@ -16,6 +16,8 @@ export { getToolDisplayName }; // Re-export for use by other modules
16
16
  import { buildBudgetStatsString } from './claude.budget-stats.lib.mjs';
17
17
  import { buildCostInfoString } from './github-cost-info.lib.mjs';
18
18
  export { buildCostInfoString };
19
+ // #1756: route gh exec calls through transient + rate-limit retry wrapper
20
+ import { execGhWithRetry } from './github-rate-limit.lib.mjs';
19
21
  // Issue #1625: Named marker constants (single source of truth) + in-memory
20
22
  // tracking for tool-posted comments. See tool-comments.lib.mjs for design.
21
23
  import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
@@ -858,9 +860,6 @@ export function isRateLimitError(error) {
858
860
  * @returns {Promise<Array>} Array of issues
859
861
  */
860
862
  export async function fetchAllIssuesWithPagination(baseCommand) {
861
- const { exec } = await import('child_process');
862
- const { promisify } = await import('util');
863
- const execAsync = promisify(exec);
864
863
  // Import log and cleanErrorMessage from lib.mjs
865
864
  const { log, cleanErrorMessage } = await import('./lib.mjs');
866
865
  try {
@@ -876,7 +875,11 @@ export async function fetchAllIssuesWithPagination(baseCommand) {
876
875
  const maxPageSize = isSearchCommand ? 100 : 1000;
877
876
  const improvedCommand = `${commandWithoutLimit} --limit ${maxPageSize}`;
878
877
  await log(` 🔎 Executing: ${improvedCommand}`, { verbose: true });
879
- const { stdout } = await execAsync(improvedCommand, { encoding: 'utf8', env: process.env });
878
+ // #1756: use execGhWithRetry so transient 5xx (e.g., 504) auto-retry
879
+ const { stdout } = await execGhWithRetry(improvedCommand, {
880
+ execOptions: { encoding: 'utf8', env: process.env },
881
+ label: 'gh search/list issues (paginated)',
882
+ });
880
883
  const endTime = Date.now();
881
884
  const issues = JSON.parse(stdout || '[]');
882
885
  await log(` ✅ Fetched ${issues.length} issues in ${Math.round((endTime - startTime) / 1000)}s`);
@@ -913,7 +916,11 @@ export async function fetchAllIssuesWithPagination(baseCommand) {
913
916
  await log(' 🔄 Falling back to default behavior...', { verbose: true });
914
917
  const fallbackCommand = baseCommand.includes('--limit') ? baseCommand : `${baseCommand} --limit 100`;
915
918
  await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay)); // Shorter delay for fallback
916
- const { stdout } = await execAsync(fallbackCommand, { encoding: 'utf8', env: process.env });
919
+ // #1756: use execGhWithRetry on fallback too
920
+ const { stdout } = await execGhWithRetry(fallbackCommand, {
921
+ execOptions: { encoding: 'utf8', env: process.env },
922
+ label: 'gh search/list issues (fallback)',
923
+ });
917
924
  const issues = JSON.parse(stdout || '[]');
918
925
  await log(` ⚠️ Fallback: fetched ${issues.length} issues (limited to 100)`, { level: 'warning' });
919
926
  return issues;
package/src/hive.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Import Sentry instrumentation first (must be before other imports)
3
3
  import './instrument.mjs';
4
- import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
4
+ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
5
5
  const earlyArgs = process.argv.slice(2);
6
6
  if (earlyArgs.includes('--version')) {
7
7
  const { getVersion } = await import('./version.lib.mjs');
@@ -112,9 +112,6 @@ if (isRunningDirectly) {
112
112
  * @returns {Promise<Array>} Array of issues
113
113
  */
114
114
  async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
115
- const { exec } = await import('child_process');
116
- const { promisify } = await import('util');
117
- const execAsync = promisify(exec);
118
115
  try {
119
116
  await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
120
117
  // Strategy 1: Try GraphQL approach first (faster but has limitations)
@@ -141,7 +138,11 @@ if (isRunningDirectly) {
141
138
 
142
139
  // Add delay for rate limiting
143
140
  await new Promise(resolve => setTimeout(resolve, 2000));
144
- const { stdout: repoOutput } = await execAsync(repoListCmd, { encoding: 'utf8', env: process.env });
141
+ // #1756: route through execGhWithRetry for transient 5xx + rate-limit
142
+ const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
143
+ execOptions: { encoding: 'utf8', env: process.env },
144
+ label: `gh api ${scope} repos (paginated)`,
145
+ });
145
146
  // Parse the output line by line, as gh api with --jq outputs one JSON object per line
146
147
  const repoLines = repoOutput
147
148
  .trim()
@@ -12,7 +12,7 @@ import { promisify } from 'node:util';
12
12
  import dayjs from 'dayjs';
13
13
  import utc from 'dayjs/plugin/utc.js';
14
14
 
15
- import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
15
+ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
16
16
  // Initialize dayjs plugins
17
17
  dayjs.extend(utc);
18
18
 
@@ -316,7 +316,8 @@ function getDisplayCpuCoresUsed(loadAvg5, cpuCount) {
316
316
  */
317
317
  export async function getGitHubRateLimits(verbose = false) {
318
318
  try {
319
- const { stdout } = await execAsync('gh api rate_limit 2>/dev/null');
319
+ // #1756: route through execGhWithRetry for transient 5xx; skip rate-limit retry budget (this is the endpoint we'd consult to know about rate limits).
320
+ const { stdout } = await execGhWithRetry('gh api rate_limit 2>/dev/null', { label: 'gh api rate_limit', maxAttempts: 1 });
320
321
  const data = JSON.parse(stdout);
321
322
 
322
323
  if (verbose) {
@@ -5,7 +5,7 @@ const { use } = eval(await (await fetch('https://unpkg.com/use-m/use.js')).text(
5
5
 
6
6
  // Use command-stream for consistent $ behavior across runtimes
7
7
  const { $: __rawDollar$ } = await use('command-stream');
8
- const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
8
+ const { wrapDollarWithGhRetry, execGhWithRetry } = await import('./github-rate-limit.lib.mjs');
9
9
  const $ = wrapDollarWithGhRetry(__rawDollar$);
10
10
  const { getLinoYargsFactory, hideBin, parseCliArgumentsWithLino } = await import('./cli-arguments.lib.mjs');
11
11
  const path = (await use('path')).default;
@@ -378,20 +378,19 @@ async function reviewer(reviewerId) {
378
378
  // Function to check if a PR already has approvals
379
379
  async function hasApprovals(prUrl) {
380
380
  try {
381
- const { exec } = await import('child_process');
382
- const { promisify } = await import('util');
383
- const execAsync = promisify(exec);
384
-
385
381
  // Extract owner, repo, and PR number from URL
386
382
  const urlMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
387
383
  if (!urlMatch) return false;
388
384
 
389
385
  const [, prOwner, prRepo, prNumber] = urlMatch;
390
386
 
391
- // Check for reviews using GitHub API
387
+ // Check for reviews using GitHub API (#1756: retry on transient 5xx + rate-limit)
392
388
  const cmd = `gh api repos/${prOwner}/${prRepo}/pulls/${prNumber}/reviews --paginate --jq '[.[] | select(.state == "APPROVED")] | length'`;
393
389
 
394
- const { stdout } = await execAsync(cmd, { encoding: 'utf8', env: process.env });
390
+ const { stdout } = await execGhWithRetry(cmd, {
391
+ execOptions: { encoding: 'utf8', env: process.env },
392
+ label: `gh api reviews (PR #${prNumber})`,
393
+ });
395
394
  const approvalCount = parseInt(stdout.trim()) || 0;
396
395
 
397
396
  if (approvalCount > 0) {
@@ -432,25 +431,24 @@ async function fetchPullRequests() {
432
431
 
433
432
  await log(` 🔎 Command: ${searchCmd}`, { verbose: true });
434
433
 
435
- // Use async exec to avoid escaping issues
436
- const { exec } = await import('child_process');
437
- const { promisify } = await import('util');
438
- const execAsync = promisify(exec);
439
- const { stdout } = await execAsync(searchCmd, { encoding: 'utf8', env: process.env });
434
+ // #1756: route through execGhWithRetry to retry transient 5xx + rate-limit
435
+ const { stdout } = await execGhWithRetry(searchCmd, {
436
+ execOptions: { encoding: 'utf8', env: process.env },
437
+ label: 'gh search prs (all PRs)',
438
+ });
440
439
  prs = JSON.parse(stdout || '[]');
441
440
  } else {
442
- // Use label filter
443
- const { exec } = await import('child_process');
444
- const { promisify } = await import('util');
445
- const execAsync = promisify(exec);
446
-
447
441
  // For repositories, use gh pr list which works better
448
442
  if (scope === 'repository') {
449
443
  const listCmd = `gh pr list --repo ${owner}/${repo} --state open --label "${argv.reviewLabel}" --limit 100 --json url,title,number,isDraft`;
450
444
  await log(` 🔎 Command: ${listCmd}`, { verbose: true });
451
445
 
452
446
  try {
453
- const { stdout } = await execAsync(listCmd, { encoding: 'utf8', env: process.env });
447
+ // #1756: retry on transient 5xx + rate-limit
448
+ const { stdout } = await execGhWithRetry(listCmd, {
449
+ execOptions: { encoding: 'utf8', env: process.env },
450
+ label: 'gh pr list (label filter)',
451
+ });
454
452
  prs = JSON.parse(stdout || '[]');
455
453
  } catch (listError) {
456
454
  await log(` ⚠️ List failed: ${listError.message.split('\n')[0]}`, { verbose: true });
@@ -481,7 +479,11 @@ async function fetchPullRequests() {
481
479
  await log(` 🔎 Command: ${searchCmd}`, { verbose: true });
482
480
 
483
481
  try {
484
- const { stdout } = await execAsync(searchCmd, { encoding: 'utf8', env: process.env });
482
+ // #1756: retry on transient 5xx + rate-limit
483
+ const { stdout } = await execGhWithRetry(searchCmd, {
484
+ execOptions: { encoding: 'utf8', env: process.env },
485
+ label: 'gh search prs (label filter)',
486
+ });
485
487
  prs = JSON.parse(stdout || '[]');
486
488
  } catch (searchError) {
487
489
  await log(` ⚠️ Search failed: ${searchError.message.split('\n')[0]}`, { verbose: true });
@@ -4,8 +4,10 @@
4
4
  */
5
5
 
6
6
  import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
7
+ import { buildPushRejectionExplanation, getRemoteBranchDivergenceSnapshot, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
8
+
9
+ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too.
7
10
 
8
- import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
9
11
  export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
10
12
  // Skip auto-PR creation if:
11
13
  // 1. Auto-PR creation is disabled AND we're not in continue mode with no PR
@@ -33,6 +35,16 @@ export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNum
33
35
  const issueUrl = argv['issue-url'] || argv._[0];
34
36
 
35
37
  try {
38
+ await synchronizeExistingIssueBranchBeforeAutoPrCreation({
39
+ tempDir,
40
+ branchName,
41
+ isContinueMode,
42
+ prNumber,
43
+ log,
44
+ formatAligned,
45
+ $,
46
+ });
47
+
36
48
  // Determine which file to create based on CLI flags
37
49
  let useClaudeFile = argv.claudeFile !== false;
38
50
  const useAutoGitkeepFile = argv.autoGitkeepFile !== false;
@@ -527,6 +539,7 @@ Proceed.
527
539
  await log('');
528
540
  throw new Error('Permission denied - need fork or collaborator access');
529
541
  } else if (errorOutput.includes('non-fast-forward') || errorOutput.includes('rejected') || errorOutput.includes('! [rejected]')) {
542
+ const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
530
543
  // Push rejected due to conflicts or diverged history
531
544
  await log('');
532
545
  await log(formatAligned('❌', 'PUSH REJECTED:', 'Branch has diverged from remote'), { level: 'error' });
@@ -534,6 +547,9 @@ Proceed.
534
547
  await log(' 🔍 What happened:');
535
548
  await log(' The remote branch has changes that conflict with your local changes.');
536
549
  await log(' This typically means someone else has pushed to this branch.');
550
+ for (const line of buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence })) {
551
+ await log(line);
552
+ }
537
553
  await log('');
538
554
  await log(' 💡 Why we cannot fix this automatically:');
539
555
  await log(' • We never use force push to preserve history');
@@ -887,16 +903,16 @@ Proceed.
887
903
  await log(` Current user: ${currentUser}`, { verbose: true });
888
904
 
889
905
  // Check if user has push access (is a collaborator or owner)
890
- // IMPORTANT: We need to completely suppress the JSON error output
891
- // Using async exec to have full control over stderr
906
+ // IMPORTANT: We need to completely suppress the JSON error output.
907
+ // Issue #1756: route through execGhWithRetry so transient 5xx
908
+ // (504) and rate-limit responses are retried instead of being
909
+ // mistaken for "user is not a collaborator".
892
910
  try {
893
- const { exec } = await import('child_process');
894
- const { promisify } = await import('util');
895
- const execAsync = promisify(exec);
896
911
  // This will throw if user doesn't have access, but won't print anything
897
- await execAsync(`gh api repos/${owner}/${repo}/collaborators/${currentUser} 2>/dev/null`, {
898
- encoding: 'utf8',
899
- env: process.env,
912
+ await execGhWithRetry(`gh api repos/${owner}/${repo}/collaborators/${currentUser} 2>/dev/null`, {
913
+ execOptions: { encoding: 'utf8', env: process.env },
914
+ label: `gh api collaborators (${owner}/${repo}/${currentUser})`,
915
+ log: msg => log(msg, { level: 'warn' }),
900
916
  });
901
917
  canAssign = true;
902
918
  await log(' User has collaborator access', { verbose: true });
@@ -1077,13 +1093,11 @@ ${prBody}`,
1077
1093
  );
1078
1094
  }
1079
1095
 
1080
- // Use async exec for gh pr create to avoid command-stream output issues
1081
- // Similar to how create-test-repo.mjs handles it
1096
+ // Issue #1756: route `gh pr create` through execGhWithRetry so a
1097
+ // single transient 5xx (e.g. `HTTP 504: 504 Gateway Timeout
1098
+ // (https://api.github.com/graphql)`) or rate-limit response retries
1099
+ // instead of aborting the whole solve session.
1082
1100
  try {
1083
- const { exec } = await import('child_process');
1084
- const { promisify } = await import('util');
1085
- const execAsync = promisify(exec);
1086
-
1087
1101
  // Write PR body to temp file to avoid shell escaping issues
1088
1102
  const prBodyFile = `/tmp/pr-body-${Date.now()}.md`;
1089
1103
  await fs.writeFile(prBodyFile, prBody);
@@ -1119,9 +1133,16 @@ ${prBody}`,
1119
1133
  let prCreateStderr = '';
1120
1134
  let assigneeFailed = false;
1121
1135
 
1136
+ const prCreateExecOptions = { encoding: 'utf8', cwd: tempDir, env: process.env };
1137
+ const prCreateRetryLogger = msg => log(msg, { level: 'warn' });
1138
+
1122
1139
  // Try to create PR with assignee first (if specified)
1123
1140
  try {
1124
- const result = await execAsync(command, { encoding: 'utf8', cwd: tempDir, env: process.env });
1141
+ const result = await execGhWithRetry(command, {
1142
+ execOptions: prCreateExecOptions,
1143
+ label: 'gh pr create',
1144
+ log: prCreateRetryLogger,
1145
+ });
1125
1146
  output = result.stdout;
1126
1147
  prCreateStderr = result.stderr || '';
1127
1148
  } catch (firstError) {
@@ -1149,7 +1170,11 @@ ${prBody}`,
1149
1170
  }
1150
1171
 
1151
1172
  // Retry without assignee - if this fails, let the error propagate to outer catch
1152
- const retryResult = await execAsync(command, { encoding: 'utf8', cwd: tempDir, env: process.env });
1173
+ const retryResult = await execGhWithRetry(command, {
1174
+ execOptions: prCreateExecOptions,
1175
+ label: 'gh pr create (no assignee)',
1176
+ log: prCreateRetryLogger,
1177
+ });
1153
1178
  output = retryResult.stdout;
1154
1179
  prCreateStderr = retryResult.stderr || '';
1155
1180
  } else {
@@ -0,0 +1,93 @@
1
+ const toCount = value => {
2
+ const parsed = Number.parseInt(String(value || '').trim(), 10);
3
+ return Number.isFinite(parsed) ? parsed : null;
4
+ };
5
+
6
+ const outputOf = result => {
7
+ const stdout = result?.stdout ? result.stdout.toString().trim() : '';
8
+ const stderr = result?.stderr ? result.stderr.toString().trim() : '';
9
+ return stdout || stderr;
10
+ };
11
+
12
+ export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null }) {
13
+ const lines = [];
14
+
15
+ if (isContinueMode && !prNumber) {
16
+ lines.push(' This run reused an existing issue branch because auto-continue found a matching branch with no PR.');
17
+ lines.push(' It is not a fresh branch created by this run, even though auto-PR creation is running now.');
18
+ } else {
19
+ lines.push(' The remote branch changed after the local branch state used for this push.');
20
+ }
21
+
22
+ if (divergence?.remoteExists && divergence.ahead !== null && divergence.behind !== null) {
23
+ lines.push(` Current branch state for ${branchName}: ${divergence.ahead} commit(s) ahead, ${divergence.behind} commit(s) behind origin/${branchName}.`);
24
+ } else if (divergence?.fetchError) {
25
+ lines.push(` Could not inspect origin/${branchName}: ${divergence.fetchError}`);
26
+ }
27
+
28
+ return lines;
29
+ }
30
+
31
+ export async function getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName }) {
32
+ const fetchResult = await $({ cwd: tempDir, silent: true })`git fetch origin refs/heads/${branchName}:refs/remotes/origin/${branchName} 2>&1`;
33
+ if (fetchResult.code !== 0) {
34
+ return {
35
+ remoteExists: false,
36
+ ahead: null,
37
+ behind: null,
38
+ fetchError: outputOf(fetchResult) || 'remote branch not found',
39
+ };
40
+ }
41
+
42
+ const aheadResult = await $({ cwd: tempDir, silent: true })`git rev-list --count origin/${branchName}..HEAD 2>&1`;
43
+ const behindResult = await $({ cwd: tempDir, silent: true })`git rev-list --count HEAD..origin/${branchName} 2>&1`;
44
+
45
+ return {
46
+ remoteExists: aheadResult.code === 0 && behindResult.code === 0,
47
+ ahead: aheadResult.code === 0 ? toCount(aheadResult.stdout) : null,
48
+ behind: behindResult.code === 0 ? toCount(behindResult.stdout) : null,
49
+ fetchError: aheadResult.code === 0 && behindResult.code === 0 ? null : outputOf(aheadResult) || outputOf(behindResult) || 'could not compare local and remote branch',
50
+ };
51
+ }
52
+
53
+ export async function synchronizeExistingIssueBranchBeforeAutoPrCreation({ tempDir, branchName, isContinueMode, prNumber, log, formatAligned, $ }) {
54
+ if (!(isContinueMode && !prNumber)) {
55
+ return null;
56
+ }
57
+
58
+ await log(formatAligned('🔎', 'Existing branch sync:', branchName));
59
+ const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
60
+ if (!divergence.remoteExists) {
61
+ await log(` ⚠️ Could not inspect origin/${branchName}: ${divergence.fetchError || 'unknown error'}`, { level: 'warning' });
62
+ return divergence;
63
+ }
64
+
65
+ await log(` Branch state before PR bootstrap commit: ${divergence.ahead} commit(s) ahead, ${divergence.behind} commit(s) behind origin/${branchName}`);
66
+
67
+ if (divergence.behind > 0 && divergence.ahead === 0) {
68
+ await log(` Fast-forwarding ${branchName} to origin/${branchName} before creating the PR bootstrap commit...`);
69
+ const mergeResult = await $({ cwd: tempDir })`git merge --ff-only origin/${branchName} 2>&1`;
70
+ if (mergeResult.code !== 0) {
71
+ await log(` ⚠️ Fast-forward failed: ${outputOf(mergeResult) || 'unknown error'}`, {
72
+ level: 'warning',
73
+ });
74
+ throw new Error('Existing issue branch could not be fast-forwarded before PR creation');
75
+ }
76
+ await log(` ✅ Branch fast-forwarded to origin/${branchName}`);
77
+ return await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
78
+ }
79
+
80
+ if (divergence.behind > 0 && divergence.ahead > 0) {
81
+ for (const line of buildPushRejectionExplanation({
82
+ branchName,
83
+ isContinueMode,
84
+ prNumber,
85
+ divergence,
86
+ })) {
87
+ await log(line);
88
+ }
89
+ throw new Error('Existing issue branch has diverged before PR creation; manual resolution required');
90
+ }
91
+
92
+ return divergence;
93
+ }
@@ -512,6 +512,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
512
512
  description: 'Disable error issue creation entirely (no prompt, no automatic creation). Overrides --auto-report-issue if both are specified.',
513
513
  default: false,
514
514
  },
515
+ 'disable-issue-auto-creation-on-error': {
516
+ type: 'boolean',
517
+ description: 'Disable creating a new GitHub error-report issue when solve fails, including the interactive prompt. This does not disable posting failure logs or comments to the original issue or pull request.',
518
+ default: false,
519
+ },
515
520
  'attach-solution-summary': {
516
521
  type: 'boolean',
517
522
  description: 'Attach the AI working session summary (from the result field) as a comment to the PR/issue after every working session. The summary is extracted from the AI tool JSON output and posted under a "Working session summary" header. Applies to the top-level run, auto-restart-until-mergeable iterations, and watch-mode iterations.',
@@ -718,6 +723,12 @@ export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn =
718
723
  if (argv.toolCheck === false) {
719
724
  argv.toolConnectionCheck = false;
720
725
  }
726
+ // Issue #1752: new flag is the explicit user-facing switch for disabling
727
+ // creation of separate solver-error issues. Keep the existing internal
728
+ // disableReportIssue path as the single behavior flag.
729
+ if (argv.disableIssueAutoCreationOnError) {
730
+ argv.disableReportIssue = true;
731
+ }
721
732
  }
722
733
 
723
734
  // --finalize normalization
@@ -12,11 +12,14 @@ import { reportError } from './sentry.lib.mjs';
12
12
  // Import GitHub error reporter
13
13
  import { handleErrorWithIssueCreation } from './github-error-reporter.lib.mjs';
14
14
 
15
+ export const isErrorIssueAutoCreationDisabled = argv => !!(argv?.disableReportIssue || argv?.disableIssueAutoCreationOnError);
16
+
15
17
  /**
16
18
  * Handles log attachment and PR closing on failure
17
19
  */
18
20
  export const handleFailure = async options => {
19
21
  const { error, errorType, shouldAttachLogs, argv, global, owner, repo, log, getLogFile, attachLogToGitHub, cleanErrorMessage, sanitizeLogContent, $ } = options;
22
+ const disableIssueCreation = isErrorIssueAutoCreationDisabled(argv);
20
23
 
21
24
  // Offer to create GitHub issue for the error
22
25
  try {
@@ -30,9 +33,9 @@ export const handleFailure = async options => {
30
33
  prNumber: global.createdPR?.number,
31
34
  errorType,
32
35
  },
33
- skipPrompt: !process.stdin.isTTY || argv.noIssueCreation,
36
+ skipPrompt: !process.stdin.isTTY || argv.noIssueCreation || disableIssueCreation,
34
37
  autoReport: argv.autoReportIssue,
35
- disableReport: argv.disableReportIssue,
38
+ disableReport: disableIssueCreation,
36
39
  });
37
40
  } catch (issueError) {
38
41
  reportError(issueError, {
@@ -49,7 +52,7 @@ export const handleFailure = async options => {
49
52
  const hasIssue = global.issueNumber;
50
53
  const targetType = hasPR ? 'pr' : hasIssue ? 'issue' : null;
51
54
  const targetNumber = hasPR ? global.createdPR.number : hasIssue ? global.issueNumber : null;
52
- const targetLabel = hasPR ? 'Pull Request' : 'Issue';
55
+ const targetLabel = hasPR ? 'Pull Request' : `original issue #${targetNumber}`;
53
56
 
54
57
  if (targetType && targetNumber) {
55
58
  await log(`\n📄 Attempting to attach failure logs to ${targetLabel}...`);
@@ -70,7 +73,7 @@ export const handleFailure = async options => {
70
73
  tool: argv.tool || 'claude',
71
74
  });
72
75
  if (logUploadSuccess) {
73
- await log(`📎 Failure log attached to ${targetLabel}`);
76
+ await log(`📎 Failure log posted to ${targetLabel}`);
74
77
  if (!hasPR && hasIssue) global.prePullRequestFailureNotificationPosted = true;
75
78
  }
76
79
  } catch (attachError) {
@@ -81,7 +84,7 @@ export const handleFailure = async options => {
81
84
  errorType,
82
85
  operation: `attach_log_to_${targetType}`,
83
86
  });
84
- await log(`⚠️ Could not attach failure log to ${targetLabel}: ${attachError.message}`, { level: 'warning' });
87
+ await log(`⚠️ Could not post failure log to ${targetLabel}: ${attachError.message}`, { level: 'warning' });
85
88
  }
86
89
  }
87
90
  }
package/src/solve.mjs CHANGED
@@ -162,6 +162,12 @@ const { isIssueUrl, isPrUrl, normalizedUrl, owner, repo, number: urlNumber } = u
162
162
  issueUrl = normalizedUrl || issueUrl;
163
163
  global.owner = owner;
164
164
  global.repo = repo;
165
+ // Issue #1752: failures before PR creation can happen during checks that run
166
+ // before the normal issue-mode setup below. Record the source issue as soon as
167
+ // the URL is validated so the pre-exit notifier can still comment on it.
168
+ if (isIssueUrl) {
169
+ global.issueNumber = urlNumber;
170
+ }
165
171
  cleanupContext.owner = owner;
166
172
  cleanupContext.repo = repo;
167
173
  // Setup unhandled error handlers to ensure log path is always shown
@@ -331,6 +337,7 @@ if (autoContinueResult.isContinueMode) {
331
337
  } else {
332
338
  // We have a branch but no PR - we'll use the existing branch and create a PR later
333
339
  await log(`🔄 Using existing branch: ${prBranch} (no PR yet - will create one)`);
340
+ await log(' This branch was created by an earlier run; this run is reusing it rather than creating a fresh branch.');
334
341
  if (argv.verbose) {
335
342
  await log(' Branch will be checked out and PR will be created during auto-PR creation phase', {
336
343
  verbose: true,
@@ -1021,7 +1028,7 @@ try {
1021
1028
  const hasIssue = global.issueNumber;
1022
1029
  const logTargetType = hasPR ? 'pr' : hasIssue ? 'issue' : null;
1023
1030
  const logTargetNumber = hasPR ? global.createdPR.number : hasIssue ? global.issueNumber : null;
1024
- const logTargetLabel = hasPR ? 'Pull Request' : 'Issue';
1031
+ const logTargetLabel = hasPR ? 'Pull Request' : `original issue #${logTargetNumber}`;
1025
1032
 
1026
1033
  if (shouldAttachLogs && logTargetType && logTargetNumber) {
1027
1034
  await log(`\n📄 Attaching failure logs to ${logTargetLabel}...`);
@@ -1054,7 +1061,7 @@ try {
1054
1061
  });
1055
1062
 
1056
1063
  if (logUploadSuccess) {
1057
- await log(` 📎 Failure logs attached to ${logTargetLabel}`);
1064
+ await log(` 📎 Failure logs posted to ${logTargetLabel}`);
1058
1065
  } else {
1059
1066
  // Issue #1212: Always show log upload failures (not just verbose)
1060
1067
  await log(' ⚠️ Failed to upload failure logs');