@link-assistant/hive-mind 2.0.10 → 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 +76 -0
- package/package.json +1 -1
- package/src/codex.lib.mjs +38 -1
- package/src/lib.mjs +4 -1
- package/src/solve.repo-setup.lib.mjs +37 -2
- package/src/solve.repository.lib.mjs +59 -5
- package/src/tool-retry.lib.mjs +42 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
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
|
+
|
|
43
|
+
## 2.0.11
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- a29902a: fix(codex): don't fail a completed turn on echoed fixture content; expand transient network auto-retry (#1955)
|
|
48
|
+
|
|
49
|
+
A `--tool codex` run failed with `❌ Codex emitted error event: Network lookup
|
|
50
|
+
skipped in fixture` even though the codex session **succeeded** (`turn.completed=1`,
|
|
51
|
+
`turn.failed=0`, working tree clean, full pricing produced). The phrase was not a
|
|
52
|
+
real error: while building an unrelated NDJSON adapter, the codex agent printed a
|
|
53
|
+
**test fixture** to its terminal. In verbose mode (`RUST_LOG=debug`) the codex CLI
|
|
54
|
+
renders OTEL telemetry (`codex_otel.log_only`, `event.name="codex.tool_result"`)
|
|
55
|
+
to stderr, including a raw `Output:` dump of each command's stdout. Our line-by-line
|
|
56
|
+
parser — which consumes stderr as well as stdout — `JSON.parse`d the fixture line
|
|
57
|
+
`{"type":"error","message":"Network lookup skipped in fixture"}` and bucketed it as
|
|
58
|
+
a genuine codex stream error.
|
|
59
|
+
- `getCodexErrorEventSummary()` (`src/codex.lib.mjs`) now treats any stray
|
|
60
|
+
**non-`turn`** error event as non-fatal whenever the turn completed successfully
|
|
61
|
+
(a `turn.completed` with no `turn.failed`). `turn.failed` remains the authoritative
|
|
62
|
+
failure signal and is never suppressed; suppressed strays are still recorded in
|
|
63
|
+
`ignoredEvents` (and logged per-event in verbose mode) for observability. This is
|
|
64
|
+
transport-agnostic — it fixes the false positive regardless of how the echo
|
|
65
|
+
arrived.
|
|
66
|
+
- `classifyRetryableError()` (`src/tool-retry.lib.mjs`, shared by
|
|
67
|
+
claude/codex/gemini/qwen/opencode) now classifies the full set of genuinely
|
|
68
|
+
transient network faults as retryable (`isCapacity:false`): DNS failures
|
|
69
|
+
(`ENOTFOUND`, `EAI_AGAIN`, "temporary failure in name resolution"), connection
|
|
70
|
+
faults (`ETIMEDOUT`, `ECONNREFUSED`, `EHOSTUNREACH`, `ENETUNREACH`, `EPIPE`,
|
|
71
|
+
"no route to host", "network is unreachable"), and gateway errors (502/504 and
|
|
72
|
+
Cloudflare `52x`); the 503 branch was broadened to "service unavailable". Aligns
|
|
73
|
+
with AWS retry guidance, RFC 9110 §15.6, and the getaddrinfo(3) man-page. The
|
|
74
|
+
fixture phrase itself is explicitly guarded to stay non-retryable.
|
|
75
|
+
|
|
76
|
+
Adds `tests/test-issue-1955-codex-fixture-false-positive.mjs` (23 tests) and a deep
|
|
77
|
+
case study in `docs/case-studies/issue-1955/`.
|
|
78
|
+
|
|
3
79
|
## 2.0.10
|
|
4
80
|
|
|
5
81
|
### Patch Changes
|
package/package.json
CHANGED
package/src/codex.lib.mjs
CHANGED
|
@@ -358,6 +358,30 @@ const isNonFatalCodexItemErrorMessage = message => /^in-process app-server event
|
|
|
358
358
|
export const getCodexErrorEventSummary = codexJsonState => {
|
|
359
359
|
const events = [];
|
|
360
360
|
const ignoredEvents = [];
|
|
361
|
+
|
|
362
|
+
// Issue #1955: When the codex turn genuinely completed (a `turn.completed`
|
|
363
|
+
// event was observed) and codex never emitted a `turn.failed`, the session
|
|
364
|
+
// SUCCEEDED. Any stray top-level `error` (stream) or nested item `error` event
|
|
365
|
+
// in that case is non-fatal and must not fail the run. Two things produce such
|
|
366
|
+
// strays:
|
|
367
|
+
// 1. A transient error codex itself retried/recovered from before completing
|
|
368
|
+
// the turn (e.g. a momentary stream blip).
|
|
369
|
+
// 2. Echoed content that merely *looks* like a codex protocol event. The
|
|
370
|
+
// codex CLI prints OTEL telemetry (`codex_otel.log_only`,
|
|
371
|
+
// event.name="codex.tool_result") containing a raw `Output:` dump of each
|
|
372
|
+
// command's stdout. When a command prints a line shaped like a protocol
|
|
373
|
+
// event — e.g. a printed NDJSON fixture line
|
|
374
|
+
// `{"type":"error","message":"Network lookup skipped in fixture"}` — our
|
|
375
|
+
// line-by-line parser misreads it as a genuine codex stream error and
|
|
376
|
+
// fails an otherwise-successful run. This was the exact false positive in
|
|
377
|
+
// issue #1955 (codex finished, working tree clean, CI passed, yet the run
|
|
378
|
+
// was reported failed).
|
|
379
|
+
// `turn.failed` is the authoritative failure signal, so it is NEVER suppressed
|
|
380
|
+
// here; only non-`turn` error events are gated on turn completion.
|
|
381
|
+
const turnCompleted = (codexJsonState?.eventCounts?.['turn.completed'] || 0) > 0;
|
|
382
|
+
const turnFailed = (codexJsonState?.turnFailures?.length || 0) > 0;
|
|
383
|
+
const sessionSucceeded = turnCompleted && !turnFailed;
|
|
384
|
+
|
|
361
385
|
const addEvents = (type, items = []) => {
|
|
362
386
|
for (const item of items) {
|
|
363
387
|
const message = unwrapCodexErrorMessage(item?.message);
|
|
@@ -369,6 +393,13 @@ export const getCodexErrorEventSummary = codexJsonState => {
|
|
|
369
393
|
});
|
|
370
394
|
continue;
|
|
371
395
|
}
|
|
396
|
+
if (type !== 'turn' && sessionSucceeded) {
|
|
397
|
+
ignoredEvents.push({
|
|
398
|
+
...event,
|
|
399
|
+
reason: 'Codex turn completed successfully with no turn.failed; stray non-turn error event is non-fatal (Issue #1955)',
|
|
400
|
+
});
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
372
403
|
events.push(event);
|
|
373
404
|
}
|
|
374
405
|
};
|
|
@@ -1146,7 +1177,13 @@ export const executeCodexCommand = async params => {
|
|
|
1146
1177
|
const codexErrorSummary = getCodexErrorEventSummary(codexJsonState);
|
|
1147
1178
|
if (codexErrorSummary.ignoredEvents.length > 0) {
|
|
1148
1179
|
const ignoredMessages = [...new Set(codexErrorSummary.ignoredEvents.map(event => event.message))].join('; ');
|
|
1149
|
-
await log(`⚠️ Ignoring non-fatal Codex
|
|
1180
|
+
await log(`⚠️ Ignoring non-fatal Codex error event(s): ${ignoredMessages}`, { level: 'warning', verbose: true });
|
|
1181
|
+
// Issue #1955: trace why each stray error event was treated as non-fatal so a
|
|
1182
|
+
// future regression (e.g. a real error wrongly suppressed) is diagnosable from
|
|
1183
|
+
// the verbose log without re-deriving the turn.completed/turn.failed state.
|
|
1184
|
+
for (const ignored of codexErrorSummary.ignoredEvents) {
|
|
1185
|
+
await log(` ↳ [${ignored.type}] "${ignored.message}" — ${ignored.reason}`, { verbose: true });
|
|
1186
|
+
}
|
|
1150
1187
|
}
|
|
1151
1188
|
if (codexErrorSummary.hasError) {
|
|
1152
1189
|
const limitSource = codexErrorSummary.message || lastMessage;
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
926
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/src/tool-retry.lib.mjs
CHANGED
|
@@ -89,6 +89,44 @@ export const classifyRetryableError = value => {
|
|
|
89
89
|
return { message, isRetryable: true, isCapacity: false, label: 'Socket/connection closed unexpectedly' };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Issue #1955: Transient DNS resolution failures. When the local resolver, the
|
|
93
|
+
// upstream DNS, or the network briefly drops, Node's undici/fetch (and the Codex
|
|
94
|
+
// CLI's reqwest stack) surface the failure with one of these signatures:
|
|
95
|
+
// getaddrinfo ENOTFOUND api.openai.com / getaddrinfo EAI_AGAIN api.github.com /
|
|
96
|
+
// "Temporary failure in name resolution" / "dns error" / "failed to lookup
|
|
97
|
+
// address information". These are 100% temporary — the host is not gone, name
|
|
98
|
+
// resolution simply failed for a moment — so the same request is safe to retry
|
|
99
|
+
// after a backoff. Switching models does not help (it is a network-layer fault),
|
|
100
|
+
// so isCapacity is false.
|
|
101
|
+
// NOTE: deliberately scoped to real resolver error tokens so it never matches
|
|
102
|
+
// unrelated text that merely contains the word "lookup" (e.g. the echoed fixture
|
|
103
|
+
// line "Network lookup skipped in fixture" from issue #1955, which is not an error
|
|
104
|
+
// at all).
|
|
105
|
+
if (lower.includes('enotfound') || lower.includes('eai_again') || lower.includes('temporary failure in name resolution') || lower.includes('getaddrinfo') || lower.includes('dns error') || lower.includes('failed to lookup address information') || lower.includes('name or service not known')) {
|
|
106
|
+
return { message, isRetryable: true, isCapacity: false, label: 'DNS resolution failure' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Issue #1955: Transient connection-level network failures from the OS/socket
|
|
110
|
+
// layer — the peer is unreachable or refused the connection for a moment, or a
|
|
111
|
+
// connect/read timed out. These are temporary (load balancer rotating, a node
|
|
112
|
+
// briefly down, a VPN/proxy hiccup, a flaky link) and the identical request
|
|
113
|
+
// typically succeeds on retry. Covers Node libuv error codes and their textual
|
|
114
|
+
// equivalents. ETIMEDOUT/"timed out" here is the connection/socket timeout
|
|
115
|
+
// (distinct from the API-level "request timed out" handled above).
|
|
116
|
+
if (lower.includes('etimedout') || lower.includes('connection timed out') || lower.includes('econnrefused') || lower.includes('connection refused') || lower.includes('ehostunreach') || lower.includes('no route to host') || lower.includes('enetunreach') || lower.includes('network is unreachable') || lower.includes('epipe') || lower.includes('eai_fail')) {
|
|
117
|
+
return { message, isRetryable: true, isCapacity: false, label: 'Transient network connection failure' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Issue #1955: Transient HTTP gateway / proxy errors (502 Bad Gateway, 504 Gateway
|
|
121
|
+
// Timeout) and Cloudflare's edge family (520 Unknown Error, 521 Web Server Is Down,
|
|
122
|
+
// 522 Connection Timed Out, 523 Origin Is Unreachable, 524 A Timeout Occurred).
|
|
123
|
+
// These come from an intermediary (CDN/proxy/load balancer), not from a request the
|
|
124
|
+
// client got wrong, and clear on their own — OpenAI/Anthropic/GitHub all front their
|
|
125
|
+
// APIs with such proxies. Safe to retry the same request after a backoff.
|
|
126
|
+
if (lower.includes('502 bad gateway') || lower.includes('bad gateway') || lower.includes('504 gateway timeout') || lower.includes('gateway time-out') || lower.includes('gateway timeout') || lower.includes('api error: 502') || lower.includes('api error: 504') || /\b52[0-4]\b/.test(lower)) {
|
|
127
|
+
return { message, isRetryable: true, isCapacity: false, label: 'Gateway error (502/504/52x)' };
|
|
128
|
+
}
|
|
129
|
+
|
|
92
130
|
// Issue #1834: Corrupted extended-thinking blocks. When extended thinking is combined with tool
|
|
93
131
|
// use, Claude Code can persist a thinking block to the session transcript with the `thinking`
|
|
94
132
|
// text emptied to "" while retaining the original `signature`. On resume/continue the block is
|
|
@@ -120,7 +158,10 @@ export const classifyRetryableError = value => {
|
|
|
120
158
|
return { message, isRetryable: true, isCapacity: false, label: 'Server rate limited (429)' };
|
|
121
159
|
}
|
|
122
160
|
|
|
123
|
-
|
|
161
|
+
// Issue #1955: broadened to also catch the bare "503 Service Unavailable" that
|
|
162
|
+
// GitHub/OpenAI/Anthropic return when a backend is briefly saturated — a
|
|
163
|
+
// transient, self-clearing condition, safe to retry with the same request.
|
|
164
|
+
if (lower.includes('api error: 503') || lower.includes('503 service unavailable') || lower.includes('service unavailable') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
|
|
124
165
|
return { message, isRetryable: true, isCapacity: false, label: '503 network error' };
|
|
125
166
|
}
|
|
126
167
|
|