@link-assistant/hive-mind 1.46.7 → 1.46.8
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 +6 -0
- package/package.json +1 -1
- package/src/github.lib.mjs +5 -4
- package/src/lib.mjs +82 -1
- package/src/solve.accept-invite.lib.mjs +12 -4
- package/src/solve.mjs +2 -2
- package/src/solve.repository.lib.mjs +5 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.46.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bcf2b9b: Retry on network issues and minimize terminal/log output differences (#1536): add ghRetry/ghCmdRetry utilities with exponential backoff for transient network errors (TCP reset, TLS timeout, connection refused, unexpected EOF). Apply retry to critical gh CLI calls: accept-invite, repository setup, auto-fork permission check, visibility detection, write permission check. Log stderr to log file on command failure for terminal/log parity. Add 'unexpected eof' to transient error detection patterns.
|
|
8
|
+
|
|
3
9
|
## 1.46.7
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
package/src/github.lib.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// GitHub-related utility functions. Check if use is already defined (when imported from solve.mjs), if not, fetch it (when running standalone)
|
|
3
3
|
if (typeof globalThis.use === 'undefined') globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
4
4
|
const { $ } = await use('command-stream'); // Use command-stream for consistent $ behavior
|
|
5
|
-
import { log, maskToken, cleanErrorMessage, isENOSPC } from './lib.mjs';
|
|
5
|
+
import { log, maskToken, cleanErrorMessage, isENOSPC, ghCmdRetry } from './lib.mjs';
|
|
6
6
|
import { reportError } from './sentry.lib.mjs';
|
|
7
7
|
import { githubLimits, timeouts } from './config.lib.mjs';
|
|
8
8
|
import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRepositories as batchCheckArchived } from './github.batch.lib.mjs';
|
|
@@ -172,8 +172,8 @@ export const checkRepositoryWritePermission = async (owner, repo, options = {})
|
|
|
172
172
|
}
|
|
173
173
|
try {
|
|
174
174
|
await log('🔍 Checking repository write permissions...');
|
|
175
|
-
// Use GitHub API to check repository permissions
|
|
176
|
-
const permResult = await $`gh api repos/${owner}/${repo} --jq .permissions
|
|
175
|
+
// Use GitHub API to check repository permissions (issue #1536: retry on network errors)
|
|
176
|
+
const permResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: `write perms ${owner}/${repo}` });
|
|
177
177
|
if (permResult.code !== 0) {
|
|
178
178
|
// API call failed - might be a private repo or network issue
|
|
179
179
|
const errorOutput = (permResult.stderr ? permResult.stderr.toString() : '') + (permResult.stdout ? permResult.stdout.toString() : '');
|
|
@@ -1437,7 +1437,8 @@ export async function handlePRNotFoundError({ prNumber, owner, repo, argv, shoul
|
|
|
1437
1437
|
*/
|
|
1438
1438
|
export async function detectRepositoryVisibility(owner, repo) {
|
|
1439
1439
|
try {
|
|
1440
|
-
|
|
1440
|
+
// Issue #1536: retry on transient network errors
|
|
1441
|
+
const visibilityResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .visibility`, { label: `visibility ${owner}/${repo}` });
|
|
1441
1442
|
if (visibilityResult.code === 0) {
|
|
1442
1443
|
const visibility = visibilityResult.stdout.toString().trim();
|
|
1443
1444
|
const isPublic = visibility === 'public';
|
package/src/lib.mjs
CHANGED
|
@@ -291,11 +291,92 @@ export const isTransientNetworkError = error => {
|
|
|
291
291
|
const output = (error?.stderr?.toString() || error?.stdout?.toString() || '').toLowerCase();
|
|
292
292
|
const combined = msg + ' ' + output;
|
|
293
293
|
|
|
294
|
-
|
|
294
|
+
// Issue #1536: added 'unexpected eof' — seen in gh CLI when connection drops mid-response
|
|
295
|
+
const transientPatterns = ['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'];
|
|
295
296
|
|
|
296
297
|
return transientPatterns.some(pattern => combined.includes(pattern));
|
|
297
298
|
};
|
|
298
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Retry a GitHub CLI / API operation with exponential backoff on transient network errors.
|
|
302
|
+
* Unlike the generic `retry()`, this function:
|
|
303
|
+
* - Only retries on transient network errors (TCP reset, TLS timeout, etc.)
|
|
304
|
+
* - Immediately rethrows non-transient errors (404, 403, auth failures)
|
|
305
|
+
* - Logs stderr to the log file when a command fails (fixing terminal/log parity)
|
|
306
|
+
*
|
|
307
|
+
* Issue #1536: Most gh commands had no retry logic, causing solve to abort on
|
|
308
|
+
* intermittent network issues.
|
|
309
|
+
*
|
|
310
|
+
* @param {Function} fn - Async function to execute (should call gh CLI or GitHub API)
|
|
311
|
+
* @param {Object} [options] - Options
|
|
312
|
+
* @param {number} [options.maxAttempts=3] - Maximum number of attempts
|
|
313
|
+
* @param {number} [options.delay=1000] - Initial delay between retries in ms
|
|
314
|
+
* @param {number} [options.backoff=2] - Backoff multiplier
|
|
315
|
+
* @param {string} [options.label='gh command'] - Label for log messages
|
|
316
|
+
* @returns {Promise<*>} Result of successful function execution
|
|
317
|
+
* @throws {Error} Last error if all attempts fail or error is non-transient
|
|
318
|
+
*/
|
|
319
|
+
export const ghRetry = async (fn, options = {}) => {
|
|
320
|
+
const { maxAttempts = 3, delay = 1000, backoff = 2, label = 'gh command' } = options;
|
|
321
|
+
|
|
322
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
323
|
+
try {
|
|
324
|
+
return await fn();
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (isTransientNetworkError(error) && attempt < maxAttempts) {
|
|
327
|
+
const waitTime = delay * Math.pow(backoff, attempt - 1);
|
|
328
|
+
await log(`⚠️ ${label}: Network error (attempt ${attempt}/${maxAttempts}), retrying in ${waitTime / 1000}s...`, { level: 'warn' });
|
|
329
|
+
await sleep(waitTime);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Execute a command-stream `$` call with retry on transient network errors.
|
|
339
|
+
* This wraps the pattern: call $`gh ...`, check exit code, handle errors.
|
|
340
|
+
* On failure, stderr is logged to the log file (fixing terminal/log parity from issue #1536).
|
|
341
|
+
*
|
|
342
|
+
* @param {Function} cmdFn - Function that returns a command-stream result (e.g., () => $`gh api ...`)
|
|
343
|
+
* @param {Object} [options] - Options
|
|
344
|
+
* @param {number} [options.maxAttempts=3] - Maximum number of attempts
|
|
345
|
+
* @param {number} [options.delay=1000] - Initial delay between retries in ms
|
|
346
|
+
* @param {number} [options.backoff=2] - Backoff multiplier
|
|
347
|
+
* @param {string} [options.label='gh command'] - Label for log messages
|
|
348
|
+
* @returns {Promise<{stdout: string, stderr: string, code: number}>} Command result
|
|
349
|
+
*/
|
|
350
|
+
export const ghCmdRetry = async (cmdFn, options = {}) => {
|
|
351
|
+
const { maxAttempts = 3, delay = 1000, backoff = 2, label = 'gh command' } = options;
|
|
352
|
+
|
|
353
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
354
|
+
const result = await cmdFn();
|
|
355
|
+
|
|
356
|
+
// Log stderr to log file for parity (issue #1536)
|
|
357
|
+
const stderr = result.stderr?.toString().trim();
|
|
358
|
+
if (stderr && result.code !== 0) {
|
|
359
|
+
await log(` [stderr] ${stderr}`, { level: 'warn' });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (result.code === 0) {
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Check if this is a transient network error worth retrying
|
|
367
|
+
const combinedOutput = (result.stdout?.toString() || '') + ' ' + (result.stderr?.toString() || '');
|
|
368
|
+
if (isTransientNetworkError({ message: combinedOutput }) && attempt < maxAttempts) {
|
|
369
|
+
const waitTime = delay * Math.pow(backoff, attempt - 1);
|
|
370
|
+
await log(`⚠️ ${label}: Network error (attempt ${attempt}/${maxAttempts}), retrying in ${waitTime / 1000}s...`, { level: 'warn' });
|
|
371
|
+
await sleep(waitTime);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Non-transient error or last attempt — return the result as-is
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
299
380
|
/**
|
|
300
381
|
* Format bytes to human readable string
|
|
301
382
|
* @param {number} bytes - Number of bytes
|
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
* this module only accepts the invitation for the specific repository or organization
|
|
6
6
|
* that is being solved. This is safer and more targeted.
|
|
7
7
|
*
|
|
8
|
+
* Issue #1536: Added retry with exponential backoff for transient network errors
|
|
9
|
+
* (TLS handshake timeout, unexpected EOF, connection reset, etc.)
|
|
10
|
+
*
|
|
8
11
|
* @see https://docs.github.com/en/rest/collaborators/invitations
|
|
9
12
|
* @see https://docs.github.com/en/rest/orgs/members
|
|
10
13
|
* @see https://github.com/link-assistant/hive-mind/issues/1373
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1536
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import { promisify } from 'util';
|
|
@@ -15,6 +19,10 @@ import { exec as execCallback } from 'child_process';
|
|
|
15
19
|
|
|
16
20
|
const exec = promisify(execCallback);
|
|
17
21
|
|
|
22
|
+
// Import retry utility (issue #1536)
|
|
23
|
+
const lib = await import('./lib.mjs');
|
|
24
|
+
const { ghRetry } = lib;
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Accepts pending GitHub repository or organization invitation for a specific target.
|
|
20
28
|
*
|
|
@@ -35,7 +43,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
|
|
|
35
43
|
|
|
36
44
|
// Check for pending repository invitation
|
|
37
45
|
try {
|
|
38
|
-
const { stdout: repoInvJson } = await exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"');
|
|
46
|
+
const { stdout: repoInvJson } = await ghRetry(() => exec('gh api /user/repository_invitations 2>/dev/null || echo "[]"'), { label: 'fetch repo invitations' });
|
|
39
47
|
const repoInvitations = JSON.parse(repoInvJson.trim() || '[]');
|
|
40
48
|
verbose && (await log(` Found ${repoInvitations.length} total pending repo invitation(s)`, { verbose: true }));
|
|
41
49
|
|
|
@@ -43,7 +51,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
|
|
|
43
51
|
|
|
44
52
|
if (matchingInv) {
|
|
45
53
|
try {
|
|
46
|
-
await exec(`gh api -X PATCH /user/repository_invitations/${matchingInv.id}`);
|
|
54
|
+
await ghRetry(() => exec(`gh api -X PATCH /user/repository_invitations/${matchingInv.id}`), { label: `accept repo invitation for ${fullName}` });
|
|
47
55
|
await log(`✅ --auto-accept-invite: Accepted repository invitation for ${fullName}`);
|
|
48
56
|
result.acceptedRepo = true;
|
|
49
57
|
} catch (e) {
|
|
@@ -58,7 +66,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
|
|
|
58
66
|
|
|
59
67
|
// Check for pending organization membership
|
|
60
68
|
try {
|
|
61
|
-
const { stdout: orgMemJson } = await exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"');
|
|
69
|
+
const { stdout: orgMemJson } = await ghRetry(() => exec('gh api /user/memberships/orgs 2>/dev/null || echo "[]"'), { label: 'fetch org memberships' });
|
|
62
70
|
const orgMemberships = JSON.parse(orgMemJson.trim() || '[]');
|
|
63
71
|
const pendingOrgs = orgMemberships.filter(m => m.state === 'pending');
|
|
64
72
|
verbose && (await log(` Found ${pendingOrgs.length} total pending org invitation(s)`, { verbose: true }));
|
|
@@ -68,7 +76,7 @@ export async function autoAcceptInviteForRepo(owner, repo, log, verbose) {
|
|
|
68
76
|
if (matchingOrg) {
|
|
69
77
|
const orgName = matchingOrg.organization.login;
|
|
70
78
|
try {
|
|
71
|
-
await exec(`gh api -X PATCH /user/memberships/orgs/${orgName} -f state=active`);
|
|
79
|
+
await ghRetry(() => exec(`gh api -X PATCH /user/memberships/orgs/${orgName} -f state=active`), { label: `accept org invitation for ${orgName}` });
|
|
72
80
|
await log(`✅ --auto-accept-invite: Accepted organization invitation for ${orgName}`);
|
|
73
81
|
result.acceptedOrg = true;
|
|
74
82
|
} catch (e) {
|
package/src/solve.mjs
CHANGED
|
@@ -239,9 +239,9 @@ const claudePath = argv.executeToolWithBun ? 'bunx claude' : process.env.CLAUDE_
|
|
|
239
239
|
if (argv.autoFork && !argv.fork) {
|
|
240
240
|
const { detectRepositoryVisibility } = githubLib;
|
|
241
241
|
|
|
242
|
-
// Check if we have write access first
|
|
242
|
+
// Check if we have write access first (issue #1536: retry on transient network errors)
|
|
243
243
|
await log('🔍 Checking repository access for auto-fork...');
|
|
244
|
-
const permResult = await $`gh api repos/${owner}/${repo} --jq .permissions
|
|
244
|
+
const permResult = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq .permissions`, { label: 'auto-fork perms' });
|
|
245
245
|
|
|
246
246
|
if (permResult.code === 0) {
|
|
247
247
|
const permissions = JSON.parse(permResult.stdout.toString().trim());
|
|
@@ -36,7 +36,7 @@ const { checkRepositoryWritePermission } = githubLib;
|
|
|
36
36
|
// Get root repository (fork source or self), or null if inaccessible
|
|
37
37
|
export const getRootRepository = async (owner, repo) => {
|
|
38
38
|
try {
|
|
39
|
-
const result = await $`gh api repos/${owner}/${repo} --jq '{fork: .fork, source: .source.full_name}' 2>&1
|
|
39
|
+
const result = await lib.ghCmdRetry(() => $`gh api repos/${owner}/${repo} --jq '{fork: .fork, source: .source.full_name}' 2>&1`, { label: `get root repo ${owner}/${repo}` });
|
|
40
40
|
if (result.code !== 0) return null;
|
|
41
41
|
|
|
42
42
|
const repoInfo = JSON.parse(result.stdout.toString().trim());
|
|
@@ -50,11 +50,10 @@ export const getRootRepository = async (owner, repo) => {
|
|
|
50
50
|
// Check if current user has a fork of the given root repository
|
|
51
51
|
export const checkExistingForkOfRoot = async rootRepo => {
|
|
52
52
|
try {
|
|
53
|
-
const userResult = await $`gh api user --jq .login
|
|
53
|
+
const userResult = await lib.ghCmdRetry(() => $`gh api user --jq .login`, { label: 'get user (fork check)' });
|
|
54
54
|
if (userResult.code !== 0) return null;
|
|
55
55
|
const currentUser = userResult.stdout.toString().trim();
|
|
56
|
-
|
|
57
|
-
const forksResult = await $`gh api repos/${rootRepo}/forks --paginate --jq '.[] | select(.owner.login == "${currentUser}") | .full_name'`;
|
|
56
|
+
const forksResult = await lib.ghCmdRetry(() => $`gh api repos/${rootRepo}/forks --paginate --jq '.[] | select(.owner.login == "${currentUser}") | .full_name'`, { label: `check forks of ${rootRepo}` });
|
|
58
57
|
if (forksResult.code !== 0) return null;
|
|
59
58
|
|
|
60
59
|
const forks = forksResult.stdout
|
|
@@ -363,8 +362,8 @@ export const setupRepository = async (argv, owner, repo, forkOwner = null, issue
|
|
|
363
362
|
await log(`\n${formatAligned('🍴', 'Fork mode:', 'ENABLED')}`);
|
|
364
363
|
await log(`${formatAligned('', 'Checking fork status...', '')}\n`);
|
|
365
364
|
|
|
366
|
-
// Get current user
|
|
367
|
-
const userResult = await $`gh api user --jq .login
|
|
365
|
+
// Get current user (issue #1536: retry on transient network errors)
|
|
366
|
+
const userResult = await lib.ghCmdRetry(() => $`gh api user --jq .login`, { label: 'get current user' });
|
|
368
367
|
if (userResult.code !== 0) {
|
|
369
368
|
await log(`${formatAligned('❌', 'Error:', 'Failed to get current user')}`);
|
|
370
369
|
await safeExit(1, 'Repository setup failed');
|