@link-assistant/hive-mind 2.15.0 → 2.15.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.
@@ -30,7 +30,13 @@ const sentryLib = await import('./sentry.lib.mjs');
30
30
  const { reportError } = sentryLib;
31
31
  // Import GitHub merge functions
32
32
  const githubMergeLib = await import('./github-merge.lib.mjs');
33
- const { checkMergePermissions, mergePullRequest, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
33
+ const { mergePullRequest, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
34
+ // Issue #2182: guard rails for this loop (wall-clock ceiling, draft self-heal,
35
+ // classified merge failures). See solve.auto-merge-guards.lib.mjs.
36
+ const autoMergeGuards = await import('./solve.auto-merge-guards.lib.mjs');
37
+ const { DRAFT_RECHECK_DELAY_MS, evaluateWatchTimeout, resolveDraftBlocker, resolveMergeFailure } = autoMergeGuards;
38
+ // Re-exported so callers and tests keep a single entry point for the watch loop.
39
+ export const { DEFAULT_WATCH_TIMEOUT_HOURS, normalizeWatchTimeoutHours } = autoMergeGuards;
34
40
  // Import GitHub functions for log attachment
35
41
  const githubLib = await import('./github.lib.mjs');
36
42
  const { sanitizeLogContent, attachLogToGitHub } = githubLib;
@@ -88,7 +94,6 @@ const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIte
88
94
  const autoRestartBudget = await import('./auto-restart-budget.lib.mjs');
89
95
  const { beginAutoRestartBudget, consumeAutoRestartIteration, formatAutoRestartLabel, formatAutoRestartLimit, hasExhaustedAutoRestartBudget } = autoRestartBudget;
90
96
  const { failOnAutoRestartBudgetExhausted } = await import('./auto-restart-exhaustion.lib.mjs');
91
- const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
92
97
  // Issue #2119: an empty pull request must not be reported as ready to merge.
93
98
  const { buildEmptyPullRequestBlocker, getPullRequestChangeStats } = await import('./pull-request-changes.lib.mjs');
94
99
  // Issue #1895: explicitly close linked issues after merging a PR into a
@@ -128,6 +133,16 @@ export const watchUntilMergeable = async params => {
128
133
  let lastKnownHeadSha = null;
129
134
  // Issue #1567: Initial cooldown to let CI register and solution logs post
130
135
  const INITIAL_COOLDOWN_SECONDS = MIN_CI_CHECK_INTERVAL_SECONDS;
136
+ // Issue #2182: this loop used to be `while (true)` with no wall-clock ceiling
137
+ // at all, so a permanently failing merge kept a single task "processing" for
138
+ // 4d 12h 13m. The timeout is the last-resort backstop below the specific
139
+ // fixes (draft detection, merge-failure classification).
140
+ const watchTimeoutHours = normalizeWatchTimeoutHours(argv.autoRestartUntilMergeableTimeoutHours ?? argv['auto-restart-until-mergeable-timeout-hours']);
141
+ const watchStartedAt = Date.now();
142
+ // Issue #2182: consecutive failed `gh pr merge` attempts, and how many times
143
+ // we silently restored "ready for review" on a pull request left as a draft.
144
+ // Mutable on purpose: the guard helpers update it in place.
145
+ const guardState = { consecutiveMergeFailures: 0, draftSelfHealCount: 0 };
131
146
 
132
147
  await log('');
133
148
  await log(formatAligned('🔄', 'AUTO-RESTART-UNTIL-MERGEABLE MODE ACTIVE', ''));
@@ -137,6 +152,7 @@ export const watchUntilMergeable = async params => {
137
152
  await log(formatAligned('', 'Initial cooldown:', `${INITIAL_COOLDOWN_SECONDS} seconds`, 2));
138
153
  await log(formatAligned('', 'Max restart iterations:', formatAutoRestartLimit(), 2));
139
154
  await log(formatAligned('', 'Max limit resumes:', formatAutoIterationLimit(maxAutoResumeIterations), 2));
155
+ await log(formatAligned('', 'Watch timeout:', watchTimeoutHours > 0 ? `${watchTimeoutHours} hour(s)` : 'unlimited', 2));
140
156
  await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
141
157
  await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
142
158
  await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, issue title/description edits, CI failures, merge conflicts', 2));
@@ -163,6 +179,15 @@ export const watchUntilMergeable = async params => {
163
179
  while (true) {
164
180
  iteration++;
165
181
  const currentTime = new Date();
182
+ // Issue #2182: hard wall-clock ceiling for the whole monitoring loop.
183
+ const watchTimeout = evaluateWatchTimeout({ watchTimeoutHours, watchStartedAt, now: Date.now(), checksCompleted: iteration - 1 });
184
+ if (watchTimeout) {
185
+ await log('');
186
+ await log(formatAligned('⏱️', 'WATCH TIMEOUT REACHED:', watchTimeout.message, 2), { level: 'error' });
187
+ await log('');
188
+ await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'watch_timeout', mode: 'auto-restart-until-mergeable', message: watchTimeout.message, details: watchTimeout.details, verbose: argv.verbose, log });
189
+ return { success: false, reason: 'watch_timeout', latestSessionId, latestAnthropicCost };
190
+ }
166
191
  const terminalState = await checkGitHubTerminalState({
167
192
  owner,
168
193
  repo,
@@ -217,6 +242,8 @@ export const watchUntilMergeable = async params => {
217
242
  }
218
243
  lastKnownHeadSha = currentHeadSha;
219
244
  consecutiveNoRunsChecks = 0;
245
+ // Issue #2182: a new commit is a genuinely new merge attempt.
246
+ guardState.consecutiveMergeFailures = 0;
220
247
  // Issue #1503: Also reset the readyToMergeCommentPosted flag when SHA changes,
221
248
  // so a new "Ready to merge" comment can be posted for the new commit's CI results.
222
249
  readyToMergeCommentPosted = false;
@@ -237,6 +264,23 @@ export const watchUntilMergeable = async params => {
237
264
  await reportAutomationStop({ $, owner, repo, targetNumber: prNumber, reason: 'terminal_github_entity_error', mode: 'auto-restart-until-mergeable', message: terminalGitHubBlocker.message, details: terminalGitHubBlocker.details, verbose: argv.verbose, log });
238
265
  return { success: false, reason: 'terminal_github_entity_error', latestSessionId, latestAnthropicCost };
239
266
  }
267
+ // Issue #2182: a pull request that is still a draft while no AI session is
268
+ // running is a leftover from a restart iteration (executeToolIteration
269
+ // drafts the PR on entry). GitHub answers mergeable=MERGEABLE/CLEAN for it,
270
+ // so nothing else in this loop notices — the merge then fails with
271
+ // "Pull Request is still a draft" on every single check. Restore
272
+ // "ready for review" here instead of burning an AI restart iteration.
273
+ if (blockers.find(b => b.type === 'draft')) {
274
+ const decision = await resolveDraftBlocker({ owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose: argv.verbose, state: guardState });
275
+ if (decision.action === 'stop') {
276
+ return { success: false, reason: decision.reason, latestSessionId, latestAnthropicCost };
277
+ }
278
+ if (decision.action === 'retry') {
279
+ lastCheckTime = currentTime;
280
+ await interruptibleSleep(DRAFT_RECHECK_DELAY_MS);
281
+ continue;
282
+ }
283
+ }
240
284
  // Issue #1503/#1918: Reset counter when CI checks exist (safety valve only for
241
285
  // consecutive "no runs"). Issue #1918: do NOT reset while getMergeBlockers is still
242
286
  // waiting for PR-triggered workflow runs to register (noWorkflowRunsForCommit). A
@@ -384,7 +428,21 @@ export const watchUntilMergeable = async params => {
384
428
  }
385
429
  return { success: true, reason: 'auto-merged', latestSessionId, latestAnthropicCost };
386
430
  } else {
387
- await log(formatAligned('⚠️', 'Auto-merge failed:', mergeResult.error || 'Unknown error', 2));
431
+ // Issue #2182: an unclassified merge failure used to be logged as
432
+ // "Will continue monitoring..." and retried every 120 seconds
433
+ // forever (5384 identical failures in the reported run). Classify
434
+ // it: self-heal what we can, stop on terminal causes, and cap the
435
+ // number of consecutive failures for everything else.
436
+ const decision = await resolveMergeFailure({ error: mergeResult.error, owner, repo, prNumber, $, log, formatAligned, reportError, reportAutomationStop, verbose: argv.verbose, state: guardState });
437
+ if (decision.action === 'stop') {
438
+ return { success: false, reason: decision.reason, error: mergeResult.error, latestSessionId, latestAnthropicCost };
439
+ }
440
+ if (decision.action === 'retry') {
441
+ lastCheckTime = currentTime;
442
+ await interruptibleSleep(DRAFT_RECHECK_DELAY_MS);
443
+ continue;
444
+ }
445
+
388
446
  await log(formatAligned('', 'Will continue monitoring...', '', 2));
389
447
  }
390
448
  } else {
@@ -1250,89 +1308,17 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
1250
1308
  const autoMergeAttempt = await import('./solve.auto-merge-attempt.lib.mjs');
1251
1309
  export const { attemptAutoMerge, reportAutoMergeBlockedByIssue } = autoMergeAttempt;
1252
1310
  /**
1253
- * Start auto-restart-until-mergeable mode
1311
+ * Start auto-restart-until-mergeable mode.
1312
+ *
1313
+ * The pre-flight checks (mode detection, base-branch guard, fork detection,
1314
+ * merge permissions) live in solve.auto-merge-preflight.lib.mjs so this file
1315
+ * stays under the 1350-line advisory threshold (issue #1593).
1254
1316
  */
1317
+ const { runAutoMergePreflight } = await import('./solve.auto-merge-preflight.lib.mjs');
1255
1318
  export const startAutoRestartUntilMergeable = async params => {
1256
- const { argv, owner, repo, prNumber } = params;
1257
- // Determine the mode
1258
- const isAutoMerge = argv.autoMerge || false;
1259
- const isAutoRestartUntilMergeable = argv.autoRestartUntilMergeable || false;
1260
- if (!isAutoMerge && !isAutoRestartUntilMergeable) {
1261
- return null; // Neither mode enabled
1262
- }
1263
- if (!prNumber) {
1264
- await log('');
1265
- await log(formatAligned('⚠️', 'Auto-restart-until-mergeable:', 'Requires a pull request'));
1266
- await log(formatAligned('', 'Note:', 'This mode only works with existing PRs', 2));
1267
- return null;
1268
- }
1269
- await ensurePullRequestBaseBranch({
1270
- owner,
1271
- repo,
1272
- prNumber,
1273
- argv,
1274
- log,
1275
- formatAligned,
1276
- $,
1277
- onMismatch: isAutoMerge ? 'throw' : 'restore',
1278
- operation: isAutoMerge ? 'auto-merge' : 'auto-restart-until-mergeable',
1279
- });
1280
- // Issue #1226: Check if running in fork mode — auto-merge cannot work without write access
1281
- if (argv.fork && isAutoMerge) {
1282
- await log('');
1283
- await log(formatAligned('⚠️', 'Auto-merge:', 'Cannot auto-merge fork PRs'));
1284
- await log(formatAligned('', 'Reason:', 'Fork contributors do not have write access to merge PRs to upstream repositories', 2));
1285
- await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
1286
- await log('');
1287
-
1288
- // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
1289
- try {
1290
- const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
1291
- const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
1292
- if (!hasExistingComment) {
1293
- const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\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)*`;
1294
- // Issue #1625: Track so this doesn't falsely count as AI-authored.
1295
- await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
1296
- await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
1297
- } else {
1298
- await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
1299
- }
1300
- } catch {
1301
- // Don't fail if comment posting fails
1302
- }
1303
- return { success: false, reason: 'fork_no_write_access' };
1304
- }
1305
- // Issue #1226: Verify merge permissions before entering the auto-merge/restart loop
1306
- if (isAutoMerge && owner && repo) {
1307
- const { canMerge, permission } = await checkMergePermissions(owner, repo, argv.verbose);
1308
- if (!canMerge) {
1309
- await log('');
1310
- await log(formatAligned('⚠️', 'Auto-merge:', 'Insufficient permissions to merge'));
1311
- await log(formatAligned('', 'Permission level:', permission || 'unknown', 2));
1312
- await log(formatAligned('', 'Required:', 'push, maintain, or admin access', 2));
1313
- await log(formatAligned('', 'Action:', 'PR is ready for manual merge by a repository maintainer', 2));
1314
- await log('');
1315
- // Issue #1323: Post a comment to the PR notifying the maintainer (with deduplication)
1316
- try {
1317
- const readyToMergeSignature = `## ✅ ${READY_TO_MERGE_MARKER}`;
1318
- const hasExistingComment = await checkForExistingComment(owner, repo, prNumber, readyToMergeSignature, argv.verbose);
1319
- if (!hasExistingComment) {
1320
- const commentBody = `## ✅ ${READY_TO_MERGE_MARKER}\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*`;
1321
- // Issue #1625: Track so this doesn't falsely count as AI-authored.
1322
- await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body: commentBody });
1323
- await log(formatAligned('', '💬 Posted merge readiness notification to PR', '', 2));
1324
- } else {
1325
- await log(formatAligned('', `Skipping duplicate "${READY_TO_MERGE_MARKER}" comment`, '', 2));
1326
- }
1327
- } catch {
1328
- // Don't fail if comment posting fails
1329
- }
1330
- return { success: false, reason: 'insufficient_permissions' };
1331
- }
1332
- }
1333
- // If --auto-merge implies --auto-restart-until-mergeable
1334
- if (isAutoMerge) {
1335
- argv.autoRestartUntilMergeable = true;
1319
+ const preflight = await runAutoMergePreflight(params);
1320
+ if (preflight.stop) {
1321
+ return preflight.result ?? null;
1336
1322
  }
1337
1323
  // Start the watch loop
1338
1324
  return await watchUntilMergeable(params);
@@ -230,6 +230,14 @@ export const SOLVE_OPTION_DEFINITIONS = {
230
230
  description: 'Maximum number of auto-restart iterations before stopping (default: 5, 0 = unlimited)',
231
231
  default: 5,
232
232
  },
233
+ // Issue #2182: the auto-restart-until-mergeable watch loop had no wall-clock
234
+ // ceiling, so a pull request that could never merge kept a task "processing"
235
+ // for 4d 12h 13m.
236
+ 'auto-restart-until-mergeable-timeout-hours': {
237
+ type: 'number',
238
+ description: 'Maximum wall-clock hours the --auto-restart-until-mergeable/--auto-merge monitoring loop may run before stopping and reporting (default: 24, 0 = unlimited)',
239
+ default: 24,
240
+ },
233
241
  'resume-on-auto-restart': {
234
242
  type: 'boolean',
235
243
  description: '[EXPERIMENTAL] Resume the previous Claude session on uncommitted-change auto-restart and send only a minimal restart prompt. Disabled by default.',
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Interrupt wrapper factory for CTRL+C handling in solve sessions.
3
3
  *
4
- * On SIGINT, auto-commits uncommitted changes and uploads session logs if --attach-logs is enabled.
4
+ * On SIGINT, auto-commits uncommitted changes, restores the pull request draft state and
5
+ * uploads session logs if --attach-logs is enabled.
5
6
  */
6
7
 
7
8
  /**
@@ -59,6 +60,22 @@ export const createInterruptWrapper = ({ cleanupContext, checkForUncommittedChan
59
60
  }
60
61
  }
61
62
 
63
+ // Issue #2182: CTRL+C ends the working session, so the pull request must go back to
64
+ // "ready for review" exactly like a normal session end. This runs before the log upload
65
+ // on purpose: it is two fast gh calls, while attaching a multi-MB log can be cut off by
66
+ // the isolation backend's SIGKILL (#2052). A pull request left in draft can never be
67
+ // merged by --auto-merge, so restoring it is the more important of the two.
68
+ try {
69
+ const { restorePullRequestsLeftInDraft } = await import('./pr-draft-state.lib.mjs');
70
+ await trace('draft-restore: start');
71
+ await restorePullRequestsLeftInDraft({ $, log, reason: 'session interrupted (CTRL+C)' });
72
+ await trace('draft-restore: done');
73
+ } catch (restoreError) {
74
+ await log(`⚠️ Could not restore pull request draft state on interrupt: ${restoreError.message}`, {
75
+ level: 'warning',
76
+ });
77
+ }
78
+
62
79
  // Upload logs if --attach-logs is enabled and we have a PR
63
80
  if (shouldAttachLogs && ctx.prNumber && ctx.owner && ctx.repo) {
64
81
  await log('📎 Uploading interrupted session logs to Pull Request...');
package/src/solve.mjs CHANGED
@@ -1185,6 +1185,17 @@ try {
1185
1185
  logsAttached = true;
1186
1186
  }
1187
1187
  }
1188
+ // Issue #2182: the AI working session is over at this point — everything below is
1189
+ // monitoring and merging, not working. The pull request must therefore be back in
1190
+ // "ready for review" BEFORE the auto-merge watch loop starts, because that loop can
1191
+ // run for days and endWorkSession() (further down) is unreachable until it returns.
1192
+ // In the reported run the PR was left in draft by an auto-restart iteration and
1193
+ // `gh pr merge` answered "Pull Request is still a draft" 2692 times over 4d 12h.
1194
+ if (prNumber) {
1195
+ const { ensurePullRequestIsReady } = await import('./pr-draft-state.lib.mjs');
1196
+ await ensurePullRequestIsReady({ owner, repo, prNumber, $, log, formatAligned, reason: 'AI working session finished', reportError });
1197
+ }
1198
+
1188
1199
  // Start auto-restart-until-mergeable mode if enabled This runs after the normal watch mode completes (if any) --auto-merge implies --auto-restart-until-mergeable
1189
1200
  if (argv.autoMerge || argv.autoRestartUntilMergeable) {
1190
1201
  const autoMergeResult = await startAutoRestartUntilMergeable({
@@ -1220,6 +1231,14 @@ try {
1220
1231
  await endWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, logsAttached });
1221
1232
  } catch (error) {
1222
1233
  await finalizeDevelopmentLog(); // Preserve failed/interrupted sessions too.
1234
+ // Issue #2182: a failed session is still a finished session. Restore every pull request
1235
+ // this process put into draft, otherwise the failure leaves it permanently unmergeable.
1236
+ try {
1237
+ const { restorePullRequestsLeftInDraft } = await import('./pr-draft-state.lib.mjs');
1238
+ await restorePullRequestsLeftInDraft({ $, log, formatAligned, reason: 'working session failed', reportError });
1239
+ } catch (restoreError) {
1240
+ await log(`Warning: Could not restore pull request draft state: ${restoreError.message}`, { level: 'warning' });
1241
+ }
1223
1242
  // Don't report authentication errors to Sentry as they are user configuration issues
1224
1243
  if (!error.isAuthError) {
1225
1244
  reportError(error, {
@@ -7,8 +7,18 @@
7
7
  // issue comment so it's excluded from --auto-attach-solution-summary's check.
8
8
  import { REPOSITORY_INITIALIZATION_REQUIRED_MARKER, postTrackedComment } from './tool-comments.lib.mjs';
9
9
  import { QUIET_PROBE } from './quiet-probe.lib.mjs'; // issue #2130: keep read-only probe payloads out of the attached log
10
+ // Issue #2192: a credential helper is never consulted for a *public* clone
11
+ // (github.com answers 200, so git never asks), which is why an authenticated
12
+ // container still got throttled as anonymous. The token has to be sent
13
+ // preemptively, before the first git network call.
14
+ import { ensureAuthenticatedGitTransport } from './git-auth-transport.lib.mjs';
10
15
 
11
16
  export async function setupRepositoryAndClone({ argv, owner, repo, forkOwner, forkRepoName, tempDir, isContinueMode, issueUrl, log, $, needsClone = true }) {
17
+ // Issue #2192: authenticate git *before* the first clone/fetch. Doing this
18
+ // afterwards (as setupGitCredentialHelper does) is too late — the clone is the
19
+ // call GitHub rejected with "temporarily limiting some unauthenticated downloads".
20
+ await ensureAuthenticatedGitTransport({ $, log, reason: 'repository setup' });
21
+
12
22
  // Set up repository and handle forking
13
23
  const { repoToClone, forkedRepo, upstreamRemote, prForkOwner } = await setupRepository(argv, owner, repo, forkOwner, issueUrl, forkRepoName);
14
24
 
@@ -31,6 +31,9 @@ import { ensureAiToolScratchIgnored } from './ai-tool-scratch.lib.mjs';
31
31
  import { parseForkFullNameFromGhOutput } from './github-repository-names.lib.mjs';
32
32
  import { checkReplacementRepositoryBranchSafety } from './solve.repository-safety.lib.mjs';
33
33
  import { buildForkReplacementBlockedReason, buildForkReplacementSafetyCheckDescription } from './solve.repository-recovery-message.lib.mjs';
34
+ // Issue #2192: GitHub throttles *anonymous* git downloads; a token must be sent
35
+ // preemptively (a credential helper is never consulted for a public repository).
36
+ import { GIT_AUTH_TRANSPORT_DISABLE, ensureAuthenticatedGitTransport, isAnonymousDownloadLimit } from './git-auth-transport.lib.mjs';
34
37
 
35
38
  // Import GitHub utilities for permission checks
36
39
  const githubLib = await import('./github.lib.mjs');
@@ -949,6 +952,14 @@ export const classifyCloneError = errorOutput => {
949
952
  return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue (interrupted transfer)' };
950
953
  }
951
954
 
955
+ // Issue #2192: GitHub refusing an *unauthenticated* download. Retryable, but
956
+ // waiting is not the remedy — the clone has to be authenticated. Checked
957
+ // before PERMISSION/NOT_FOUND/RATE_LIMIT because GitHub's wording ("limiting",
958
+ // "retry later or authenticate") overlaps all three.
959
+ if (isAnonymousDownloadLimit(errorOutput)) {
960
+ return { type: 'ANONYMOUS_RATE_LIMIT', retryable: true, description: 'GitHub is limiting unauthenticated downloads (this clone was not authenticated)' };
961
+ }
962
+
952
963
  // Authentication/permission errors - not retryable
953
964
  if (output.includes('error: 401') || output.includes('error: 403') || output.includes('authentication failed') || output.includes('permission denied')) {
954
965
  return { type: 'PERMISSION', retryable: false, description: 'Authentication or permission error' };
@@ -1075,6 +1086,9 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1075
1086
  await log(' • Network connectivity issues');
1076
1087
  if (errorClassification.type === 'TRANSIENT') await log(' • GitHub server issues (temporary)');
1077
1088
  if (errorClassification.type === 'RATE_LIMIT') await log(' • API rate limiting exceeded');
1089
+ // Issue #2192: the request never carried an Authorization header, so
1090
+ // GitHub counted it against the anonymous budget regardless of `gh auth status`.
1091
+ if (errorClassification.type === 'ANONYMOUS_RATE_LIMIT') await log(' • The clone was sent anonymously — GitHub throttles unauthenticated downloads');
1078
1092
  // Issue #1957: the transfer started but was interrupted (e.g. the connection
1079
1093
  // dropped while reading the pack). The retries above were already exhausted.
1080
1094
  if (errorClassification.type === 'NETWORK') await log(' • Connection dropped mid-transfer (the clone was interrupted before completing)');
@@ -1087,6 +1101,11 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1087
1101
  if (argv.fork) await log(` 4. Check fork: gh repo view ${repoToClone}`);
1088
1102
  if (errorClassification.type === 'TRANSIENT') await log(' 5. Wait and retry / check: https://www.githubstatus.com');
1089
1103
  if (errorClassification.type === 'RATE_LIMIT') await log(' 5. Wait for rate limit to reset or use --token with different token');
1104
+ if (errorClassification.type === 'ANONYMOUS_RATE_LIMIT') {
1105
+ await log(' 5. Make sure a token is available to git: gh auth token (or set GH_TOKEN)');
1106
+ await log(' 6. Repair the git/gh state non-interactively: gh-setup-git-identity --repair');
1107
+ await log(` 7. Hive Mind normally authenticates git itself; if that was turned off, unset ${GIT_AUTH_TRANSPORT_DISABLE}`);
1108
+ }
1090
1109
  if (errorClassification.type === 'NETWORK') {
1091
1110
  await log(' 5. Check your network connection / VPN / proxy, then re-run the command');
1092
1111
  await log(' 6. On slow or unstable links, a shallower history transfers faster and is less');
@@ -1100,6 +1119,13 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1100
1119
  // Retryable error and we have attempts left
1101
1120
  const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
1102
1121
  await log(`${formatAligned('⚠️', 'Clone failed:', errorClassification.description)}`);
1122
+ // Issue #2192: auto-recovery. GitHub rejected the download as anonymous, so a
1123
+ // plain retry would be rejected the same way. Authenticate the transport (and,
1124
+ // if no token is reachable, let `gh-setup-git-identity --repair` restore the
1125
+ // gh state non-interactively) before spending the next attempt.
1126
+ if (errorClassification.type === 'ANONYMOUS_RATE_LIMIT') {
1127
+ await ensureAuthenticatedGitTransport({ $, log, repair: true, reason: 'GitHub rejected the clone as unauthenticated' });
1128
+ }
1103
1129
  await log(`${formatAligned('⏳', 'Retrying:', `Waiting ${delay / 1000}s before attempt ${attempt + 1}/${maxRetries}...`)}`);
1104
1130
  if (errorClassification.type === 'RATE_LIMIT') {
1105
1131
  await log(' 💡 Tip: Rate limiting detected - using longer delay');