@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.10",
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/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 item error event(s): ${ignoredMessages}`, { level: 'warning', verbose: true });
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
- 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');
@@ -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
- if (lower.includes('api error: 503') || (lower.includes('503') && (lower.includes('upstream connect error') || lower.includes('remote connection failure')))) {
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