@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 +36 -0
- package/package.json +1 -1
- package/src/codex.lib.mjs +38 -1
- package/src/tool-retry.lib.mjs +42 -1
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
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/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
|
|