@link-assistant/hive-mind 2.0.10 → 2.0.11

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,41 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.11
4
+
5
+ ### Patch Changes
6
+
7
+ - a29902a: fix(codex): don't fail a completed turn on echoed fixture content; expand transient network auto-retry (#1955)
8
+
9
+ A `--tool codex` run failed with `❌ Codex emitted error event: Network lookup
10
+ skipped in fixture` even though the codex session **succeeded** (`turn.completed=1`,
11
+ `turn.failed=0`, working tree clean, full pricing produced). The phrase was not a
12
+ real error: while building an unrelated NDJSON adapter, the codex agent printed a
13
+ **test fixture** to its terminal. In verbose mode (`RUST_LOG=debug`) the codex CLI
14
+ renders OTEL telemetry (`codex_otel.log_only`, `event.name="codex.tool_result"`)
15
+ to stderr, including a raw `Output:` dump of each command's stdout. Our line-by-line
16
+ parser — which consumes stderr as well as stdout — `JSON.parse`d the fixture line
17
+ `{"type":"error","message":"Network lookup skipped in fixture"}` and bucketed it as
18
+ a genuine codex stream error.
19
+ - `getCodexErrorEventSummary()` (`src/codex.lib.mjs`) now treats any stray
20
+ **non-`turn`** error event as non-fatal whenever the turn completed successfully
21
+ (a `turn.completed` with no `turn.failed`). `turn.failed` remains the authoritative
22
+ failure signal and is never suppressed; suppressed strays are still recorded in
23
+ `ignoredEvents` (and logged per-event in verbose mode) for observability. This is
24
+ transport-agnostic — it fixes the false positive regardless of how the echo
25
+ arrived.
26
+ - `classifyRetryableError()` (`src/tool-retry.lib.mjs`, shared by
27
+ claude/codex/gemini/qwen/opencode) now classifies the full set of genuinely
28
+ transient network faults as retryable (`isCapacity:false`): DNS failures
29
+ (`ENOTFOUND`, `EAI_AGAIN`, "temporary failure in name resolution"), connection
30
+ faults (`ETIMEDOUT`, `ECONNREFUSED`, `EHOSTUNREACH`, `ENETUNREACH`, `EPIPE`,
31
+ "no route to host", "network is unreachable"), and gateway errors (502/504 and
32
+ Cloudflare `52x`); the 503 branch was broadened to "service unavailable". Aligns
33
+ with AWS retry guidance, RFC 9110 §15.6, and the getaddrinfo(3) man-page. The
34
+ fixture phrase itself is explicitly guarded to stay non-retryable.
35
+
36
+ Adds `tests/test-issue-1955-codex-fixture-false-positive.mjs` (23 tests) and a deep
37
+ case study in `docs/case-studies/issue-1955/`.
38
+
3
39
  ## 2.0.10
4
40
 
5
41
  ### 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.11",
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;
@@ -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