@link-assistant/hive-mind 2.0.11 → 2.0.12

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,45 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.12
4
+
5
+ ### Patch Changes
6
+
7
+ - f17bac4: fix(clone): detect interrupted clones that exit 0, retry them, and explain the failure instead of the bare "Failed to get current branch" (#1957)
8
+
9
+ A `/solve` run crashed with the unactionable message `Failed to get current branch`.
10
+ Root cause: `gh repo clone` (and the `git clone` it wraps) **exited 0 even though the
11
+ transfer was interrupted** mid-stream (`fetch-pack: unexpected disconnect while reading
12
+ sideband packet`), leaving **no `.git` directory** (`size=0 B`). The solver trusted the
13
+ exit code, logged `✅ Cloned to:`, then every subsequent git command failed with
14
+ `fatal: not a git repository`; the first to propagate it (`git branch --show-current` in
15
+ `verifyDefaultBranchAndStatus`) threw the bare error with no clue about what went wrong
16
+ or how to recover.
17
+
18
+ Defense-in-depth fix applied across the codebase:
19
+ - `cloneRepository()` (`src/solve.repository.lib.mjs`) no longer trusts the exit code:
20
+ after `gh repo clone` it validates the result with `git rev-parse --is-inside-work-tree`
21
+ and only treats the clone as successful when the exit code is 0 **and** a real working
22
+ tree exists. In `--verbose` mode it logs why a 0-exit clone was rejected.
23
+ - New exported helper `cleanPartialClone()` empties the target directory before each
24
+ retry so a partial clone does not make `gh repo clone <dir>` fail with "directory
25
+ exists and is not empty".
26
+ - `classifyCloneError()` now classifies the interrupted-transfer vocabulary
27
+ (`unexpected disconnect`, `sideband`, `early eof`, `the remote end hung up`,
28
+ `rpc failed`, `fetch-pack`, `index-pack failed`, `transfer closed`) as a retryable
29
+ `NETWORK` error, so the existing 3× exponential-backoff retry loop recovers from it.
30
+ 404 / ENOSPC / auth failures stay non-retryable.
31
+ - `isTransientNetworkError()` (`src/lib.mjs`, shared by many gh/git retry call sites)
32
+ gains the same vocabulary, so the fix propagates everywhere — not just the clone path.
33
+ - Both failure points now print concrete, root-cause-obvious guidance: the clone-failure
34
+ path adds NETWORK causes/fixes, and `verifyDefaultBranchAndStatus()`
35
+ (`src/solve.repo-setup.lib.mjs`) detects `not a git repository` and logs an
36
+ `INCOMPLETE CLONE DETECTED` block (What happened / Error details / How to fix:
37
+ check network·VPN·proxy, re-run — clones auto-retry, verify access, check GitHub
38
+ status) instead of the bare message.
39
+
40
+ Adds `tests/test-issue-1957-incomplete-clone.mjs` (26 assertions) and a deep case study
41
+ in `docs/case-studies/issue-1957/`.
42
+
3
43
  ## 2.0.11
4
44
 
5
45
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/lib.mjs CHANGED
@@ -471,7 +471,10 @@ export const isTransientNetworkError = error => {
471
471
  const combined = msg + ' ' + output;
472
472
 
473
473
  // Issue #1536: added 'unexpected eof' — seen in gh CLI when connection drops mid-response
474
- 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'];
474
+ // Issue #1957: added git fetch-pack/sideband disconnect patterns seen when a
475
+ // `gh repo clone` / `git clone` connection drops mid-transfer, leaving an incomplete
476
+ // (or missing) working tree even though the wrapper can exit 0.
477
+ 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', 'unexpected disconnect', 'sideband', 'early eof', 'the remote end hung up', 'rpc failed', 'fetch-pack', 'index-pack failed', 'remote end hung up unexpectedly', 'transfer closed'];
475
478
 
476
479
  return transientPatterns.some(pattern => combined.includes(pattern));
477
480
  };
@@ -65,9 +65,44 @@ export async function verifyDefaultBranchAndStatus({ tempDir, log, formatAligned
65
65
  const defaultBranchResult = await $({ cwd: tempDir })`git branch --show-current`;
66
66
 
67
67
  if (defaultBranchResult.code !== 0) {
68
+ // Issue #1957: the most common cause of this failure is an interrupted/incomplete
69
+ // clone — `git` reports "fatal: not a git repository" because there is no `.git`
70
+ // directory. The previous message ("Failed to get current branch") gave the user
71
+ // no idea what happened or what to do. Detect the incomplete-clone case and give
72
+ // concrete, actionable instructions instead.
73
+ const branchErr = ((defaultBranchResult.stderr?.toString() || '') + (defaultBranchResult.stdout?.toString() || '')).trim();
74
+ const isNotAGitRepo = /not a git repository/i.test(branchErr);
75
+
76
+ await log('');
77
+ await log(`${formatAligned('❌', isNotAGitRepo ? 'INCOMPLETE CLONE DETECTED' : 'FAILED TO READ CURRENT BRANCH', '')}`, { level: 'error' });
78
+ await log('');
79
+ await log(' 🔍 What happened:');
80
+ if (isNotAGitRepo) {
81
+ await log(` The working directory is not a valid git repository: ${tempDir}`);
82
+ await log(' This almost always means the clone was interrupted before it finished');
83
+ await log(' (e.g. "fetch-pack: unexpected disconnect while reading sideband packet"),');
84
+ await log(' so no .git directory was created.');
85
+ } else {
86
+ await log(` Could not determine the current branch in: ${tempDir}`);
87
+ }
88
+ await log('');
89
+ await log(' 📦 Error details:');
90
+ for (const line of (branchErr || 'Unknown error').split('\n')) {
91
+ if (line.trim()) await log(` ${line}`);
92
+ }
93
+ await log('');
94
+ await log(' 🔧 How to fix:');
95
+ await log(' 1. Check your network connection / VPN / proxy (interrupted transfers are usually transient)');
96
+ await log(' 2. Re-run the command — the solver retries clones automatically and a fresh run usually succeeds');
97
+ if (owner && repo) await log(` 3. Verify access to the repository: gh repo view ${owner}/${repo}`);
98
+ await log(` ${owner && repo ? '4' : '3'}. Check GitHub status if it keeps failing: https://www.githubstatus.com`);
99
+ await log('');
100
+ await log(' ℹ️ If this is reproducible, ask a Hive Mind administrator to inspect the solver terminal log for the clone error.');
101
+ await log('');
102
+
68
103
  await log('Error: Failed to get current branch');
69
- await log(defaultBranchResult.stderr ? defaultBranchResult.stderr.toString() : 'Unknown error');
70
- throw new Error('Failed to get current branch');
104
+ if (branchErr) await log(branchErr);
105
+ throw new Error(isNotAGitRepo ? 'Failed to get current branch - working directory is not a git repository (incomplete clone)' : 'Failed to get current branch');
71
106
  }
72
107
 
73
108
  let defaultBranch = defaultBranchResult.stdout.toString().trim();
@@ -922,8 +922,12 @@ export const classifyCloneError = errorOutput => {
922
922
  }
923
923
 
924
924
  // Network-related errors - typically retryable
925
- if (output.includes('connection refused') || output.includes('connection timed out') || output.includes('connection reset') || output.includes('unable to connect') || output.includes('network is unreachable') || output.includes('ssl error')) {
926
- return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue' };
925
+ // Issue #1957: git fetch-pack/sideband disconnects (e.g.
926
+ // "fetch-pack: unexpected disconnect while reading sideband packet",
927
+ // "early EOF", "the remote end hung up unexpectedly", "RPC failed",
928
+ // "index-pack failed") leave an incomplete or missing clone but are transient.
929
+ if (output.includes('connection refused') || output.includes('connection timed out') || output.includes('connection reset') || output.includes('unable to connect') || output.includes('network is unreachable') || output.includes('ssl error') || output.includes('unexpected disconnect') || output.includes('sideband') || output.includes('early eof') || output.includes('remote end hung up') || output.includes('rpc failed') || output.includes('fetch-pack') || output.includes('index-pack failed') || output.includes('transfer closed')) {
930
+ return { type: 'NETWORK', retryable: true, description: 'Network connectivity issue (interrupted transfer)' };
927
931
  }
928
932
 
929
933
  // Authentication/permission errors - not retryable
@@ -945,6 +949,24 @@ export const classifyCloneError = errorOutput => {
945
949
  return { type: 'UNKNOWN', retryable: true, description: 'Unknown error' };
946
950
  };
947
951
 
952
+ // Issue #1957: remove leftovers from an interrupted clone so a retry can start clean.
953
+ // We empty the directory in place (rather than removing it) because the path was
954
+ // created up-front by setupTempDirectory and may be the configured working directory.
955
+ export const cleanPartialClone = async tempDir => {
956
+ try {
957
+ const entries = await fs.readdir(tempDir);
958
+ for (const entry of entries) {
959
+ await fs.rm(path.join(tempDir, entry), { recursive: true, force: true });
960
+ }
961
+ } catch (error) {
962
+ // Directory may not exist yet, or be unreadable — non-fatal; the retry/clone
963
+ // will surface any real problem with a clearer message.
964
+ if (error?.code !== 'ENOENT') {
965
+ reportError(error, { context: 'clean_partial_clone', tempDir, operation: 'empty_directory' });
966
+ }
967
+ }
968
+ };
969
+
948
970
  // Clone repository and set up remotes with retry mechanism
949
971
  export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) => {
950
972
  const maxRetries = 3;
@@ -959,9 +981,25 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
959
981
 
960
982
  // Use 2>&1 to capture all output and filter "Cloning into" message
961
983
  const cloneResult = await $`gh repo clone ${repoToClone} ${tempDir} 2>&1`;
962
-
963
- // Verify clone was successful
984
+ const cloneOutput = (cloneResult.stdout || cloneResult.stderr || '').toString().trim();
985
+
986
+ // Issue #1957: `gh repo clone` (and the `git clone` it wraps) can exit 0 even when
987
+ // the underlying transfer was interrupted — e.g. "fetch-pack: unexpected disconnect
988
+ // while reading sideband packet" — leaving an incomplete or completely missing
989
+ // working tree (no `.git`). Trusting the exit code alone made the solver report
990
+ // "✅ Cloned to:" and then crash much later with the unhelpful "Failed to get
991
+ // current branch". Verify the clone actually produced a usable git repository.
992
+ let repoIsValid = false;
964
993
  if (cloneResult.code === 0) {
994
+ const validityCheck = await $({ cwd: tempDir })`git rev-parse --is-inside-work-tree 2>&1`;
995
+ repoIsValid = validityCheck.code === 0 && validityCheck.stdout.toString().trim() === 'true';
996
+ if (!repoIsValid && argv.verbose) {
997
+ await log(`${formatAligned('🔧', 'Clone validation:', `git rev-parse failed despite exit 0 — ${(validityCheck.stdout || validityCheck.stderr || '').toString().trim().split('\n')[0]}`)}`);
998
+ }
999
+ }
1000
+
1001
+ // Verify clone was successful (exit code 0 AND a valid working tree exists)
1002
+ if (cloneResult.code === 0 && repoIsValid) {
965
1003
  await log(`${formatAligned('✅', 'Cloned to:', tempDir)}`);
966
1004
 
967
1005
  // Verify and fix remote configuration
@@ -975,7 +1013,15 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
975
1013
  }
976
1014
 
977
1015
  // Clone failed - analyze error and determine if retry is appropriate
978
- const errorOutput = (cloneResult.stderr || cloneResult.stdout || 'Unknown error').toString().trim();
1016
+ // Issue #1957: when the wrapper exited 0 but left no valid repo, surface the
1017
+ // interrupted-transfer output (which carries the real "unexpected disconnect"
1018
+ // reason) so it is classified as a retryable network error rather than UNKNOWN.
1019
+ const errorOutput = cloneResult.code === 0 && !repoIsValid ? cloneOutput || 'Clone exited 0 but no valid git repository was created (interrupted transfer / incomplete clone)' : (cloneResult.stderr || cloneResult.stdout || 'Unknown error').toString().trim();
1020
+
1021
+ // Issue #1957: a partial clone can leave stray files behind that make a retry of
1022
+ // `gh repo clone <dir>` fail with "directory exists and is not empty". Clean the
1023
+ // target directory before classifying/retrying so the next attempt starts fresh.
1024
+ await cleanPartialClone(tempDir);
979
1025
 
980
1026
  const errorClassification = classifyCloneError(errorOutput);
981
1027
 
@@ -1015,6 +1061,9 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1015
1061
  await log(' • Network connectivity issues');
1016
1062
  if (errorClassification.type === 'TRANSIENT') await log(' • GitHub server issues (temporary)');
1017
1063
  if (errorClassification.type === 'RATE_LIMIT') await log(' • API rate limiting exceeded');
1064
+ // Issue #1957: the transfer started but was interrupted (e.g. the connection
1065
+ // dropped while reading the pack). The retries above were already exhausted.
1066
+ if (errorClassification.type === 'NETWORK') await log(' • Connection dropped mid-transfer (the clone was interrupted before completing)');
1018
1067
  if (argv.fork) await log(' • Fork not ready yet (try again in a moment)');
1019
1068
  await log('');
1020
1069
  await log(' 🔧 How to fix:');
@@ -1024,6 +1073,11 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1024
1073
  if (argv.fork) await log(` 4. Check fork: gh repo view ${repoToClone}`);
1025
1074
  if (errorClassification.type === 'TRANSIENT') await log(' 5. Wait and retry / check: https://www.githubstatus.com');
1026
1075
  if (errorClassification.type === 'RATE_LIMIT') await log(' 5. Wait for rate limit to reset or use --token with different token');
1076
+ if (errorClassification.type === 'NETWORK') {
1077
+ await log(' 5. Check your network connection / VPN / proxy, then re-run the command');
1078
+ await log(' 6. On slow or unstable links, a shallower history transfers faster and is less');
1079
+ await log(' likely to be interrupted; ask a Hive Mind administrator if clone tuning is available');
1080
+ }
1027
1081
  await log('');
1028
1082
  }
1029
1083
  await safeExit(1, 'Repository setup failed');