@link-assistant/hive-mind 2.11.10 → 2.11.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 +73 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +119 -20
- package/src/cancelled-ci-rerun.lib.mjs +3 -1
- package/src/claude.lib.mjs +5 -4
- package/src/codex-health.lib.mjs +25 -0
- package/src/codex.lib.mjs +26 -55
- package/src/codex.run-diagnostics.lib.mjs +7 -0
- package/src/codex.usage-fields.lib.mjs +59 -0
- package/src/error-text.lib.mjs +130 -0
- package/src/gemini.lib.mjs +4 -2
- package/src/interactive-codex-events.lib.mjs +4 -1
- package/src/lib.mjs +29 -3
- package/src/opencode.lib.mjs +1 -1
- package/src/qwen.lib.mjs +2 -13
- package/src/solve.pre-pr-failure-notifier.lib.mjs +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- be63949: Render structured tool error payloads as readable text instead of
|
|
8
|
+
`[object Object]` (issue #2141).
|
|
9
|
+
|
|
10
|
+
A `solve --tool agent --model formal-ai` run failed 22 seconds in and published
|
|
11
|
+
one artefact: a "Solution Draft Failed" comment reading `AGENT execution failed
|
|
12
|
+
with Agent reported error: [object Object]`. `--attach-logs` was off, so that
|
|
13
|
+
string was the entire post-mortem and the real cause is unrecoverable.
|
|
14
|
+
`@link-assistant/agent` emits `NamedError.toObject()` — `{"type":"error",
|
|
15
|
+
"error":{"name":"…","data":{"message":"…"}}}` — and the adapter interpolated that
|
|
16
|
+
object into a template literal. The `|| JSON.stringify(msg)` fallback that would
|
|
17
|
+
have saved the diagnosis was unreachable, because the object is truthy.
|
|
18
|
+
|
|
19
|
+
- New `src/error-text.lib.mjs` renders strings, `Error` instances, `NamedError`
|
|
20
|
+
payloads, nested `{error:{…}}` envelopes and arrays into one readable line —
|
|
21
|
+
circular-safe, depth-limited, truncated at 2000 characters, never a
|
|
22
|
+
placeholder.
|
|
23
|
+
- A codebase-wide audit found the same defect class in nine places across six
|
|
24
|
+
adapters; all now use the shared renderer. One of them was not cosmetic:
|
|
25
|
+
`codex.lib.mjs` guarded `error` / `turn.failed` events with `typeof
|
|
26
|
+
data.message === 'string'` and therefore **discarded** object-shaped failures,
|
|
27
|
+
reporting the run as a success.
|
|
28
|
+
- Defence in depth: `isMeaningfulErrorText` and `extractToolErrorCore` now reject
|
|
29
|
+
a core polluted by `[object Object]`, so any site this audit missed degrades to
|
|
30
|
+
the honest `AGENT execution failed` rather than the misleading long form.
|
|
31
|
+
- The agent adapter now fails fast when the CLI logs a fatal startup error
|
|
32
|
+
(`ProviderModelNotFoundError` and friends) and then exits 0 with no error event
|
|
33
|
+
and no output — a silent failure reproduced against agent CLI 0.25.5 and
|
|
34
|
+
reported upstream.
|
|
35
|
+
- `--verbose` dumps the raw JSON of every error and fatal log record, and the
|
|
36
|
+
pre-PR failure comment now tells the reader to rerun with
|
|
37
|
+
`--attach-logs --verbose`.
|
|
38
|
+
|
|
39
|
+
Case study, raw evidence and the upstream reports:
|
|
40
|
+
`docs/case-studies/issue-2141/README.md`.
|
|
41
|
+
|
|
42
|
+
## 2.11.11
|
|
43
|
+
|
|
44
|
+
### Patch Changes
|
|
45
|
+
|
|
46
|
+
- 36e7976: Keep stream provenance on mirrored agent output and make the Codex completion
|
|
47
|
+
gate explain itself (issue #2140).
|
|
48
|
+
|
|
49
|
+
A `solve --tool codex` run that had finished its work — PR updated, all 46
|
|
50
|
+
check-runs green, `turn.completed` received — was still failed by the completion
|
|
51
|
+
gate with `turn.started=3, turn.completed=1`. Replaying the real 96k-line log
|
|
52
|
+
through the parser shows the two extra `turn.started` records arrived on Codex's
|
|
53
|
+
**stderr**, inside an OTEL `codex.tool_result` dump of a command that merely read
|
|
54
|
+
a stored NDJSON log file from disk. This is the issue #2136 defect on a binary
|
|
55
|
+
released minutes before that fix shipped; current builds already gate correctly.
|
|
56
|
+
Three residual gaps remain, and this change closes them:
|
|
57
|
+
|
|
58
|
+
- `log()` accepted an `options.stream` hint and silently discarded it, so every
|
|
59
|
+
agent CLI's mirrored output — both streams, all five tools — was written as
|
|
60
|
+
`[INFO]` on our stdout. It is now tagged `[STDOUT]` / `[STDERR]`, matching the
|
|
61
|
+
tags the stdio interceptor already uses, and mirrored stderr goes to our
|
|
62
|
+
stderr so piping stdout yields only what the child wrote there. An explicit
|
|
63
|
+
`level` still wins, and callers that pass no stream are unchanged.
|
|
64
|
+
- `codex exec` starts exactly one thread, so a `thread.started` on the protocol
|
|
65
|
+
stream announcing a different `thread_id` is proof of echoed output. Those ids
|
|
66
|
+
are now collected and reported (`🧬 Foreign thread IDs seen on the codex
|
|
67
|
+
protocol stream`).
|
|
68
|
+
- The completion-failure reason carried counts and nothing else, which made a
|
|
69
|
+
false positive impossible to refute from the posted comment. It now also
|
|
70
|
+
states the ordered turn lifecycle, how many `turn.started` records were
|
|
71
|
+
discarded as echoed telemetry, and any foreign thread id seen.
|
|
72
|
+
|
|
73
|
+
No gate outcome changes: a genuinely truncated turn still fails, and a completed
|
|
74
|
+
run with echoed events still passes.
|
|
75
|
+
|
|
3
76
|
## 2.11.10
|
|
4
77
|
|
|
5
78
|
### Patch Changes
|
package/package.json
CHANGED
package/src/agent.lib.mjs
CHANGED
|
@@ -27,12 +27,98 @@ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue
|
|
|
27
27
|
import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
|
|
28
28
|
import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
|
|
29
29
|
import { createJsonStreamScanner, parseJsonRecords } from './json-stream.lib.mjs';
|
|
30
|
+
import { firstErrorText, stringifyErrorValue } from './error-text.lib.mjs';
|
|
30
31
|
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
31
32
|
import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
|
|
32
33
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
33
34
|
|
|
34
35
|
export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Render one streamed agent error record as human-readable text (issue #2141).
|
|
39
|
+
*
|
|
40
|
+
* `@link-assistant/agent` 0.25.x publishes `NamedError.toObject()` under the
|
|
41
|
+
* `error` key: `{"type":"error","error":{"name":"RetryTimeoutExceededError",
|
|
42
|
+
* "data":{"message":"…"}}}`. The previous chain `data.message || data.error ||
|
|
43
|
+
* raw.substring(0, 100)` returned that *object*, and interpolating it into
|
|
44
|
+
* `Agent reported error: ${…}` produced the reason published to GitHub in issue
|
|
45
|
+
* #2141: "AGENT execution failed with Agent reported error: [object Object]".
|
|
46
|
+
*
|
|
47
|
+
* @param {object} record - a sanitized JSON record from the agent stream.
|
|
48
|
+
* @param {string} [raw] - the raw record text, used as a last resort.
|
|
49
|
+
* @returns {string} readable error text, never `[object Object]`.
|
|
50
|
+
*/
|
|
51
|
+
export const extractAgentErrorText = (record, raw = '') => {
|
|
52
|
+
const fallback = String(raw || '').substring(0, 200) || 'Agent emitted an error event without any details';
|
|
53
|
+
return firstErrorText([record?.message, record?.error, record?.data, record], { fallback });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Model/provider initialization failures that leave the session unable to do any
|
|
58
|
+
* work at all (issue #2141).
|
|
59
|
+
*/
|
|
60
|
+
const FATAL_AGENT_LOG_PATTERNS = [/ProviderModelNotFoundError/i, /ProviderInitError/i, /NoSuchModelError/i, /ModelNotFoundError/i, /failed to initialize .*model/i];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect an agent `log` record that reports a fatal startup failure (issue #2141).
|
|
64
|
+
*
|
|
65
|
+
* Reproduced locally with agent CLI 0.25.5: `agent --model nonexistent/model`
|
|
66
|
+
* prints
|
|
67
|
+
*
|
|
68
|
+
* {"type":"log","level":"error","service":"session.prompt",
|
|
69
|
+
* "error":"ProviderModelNotFoundError",
|
|
70
|
+
* "hint":"Check that the model exists in the provider",
|
|
71
|
+
* "message":"Failed to initialize specified model - NOT falling back to default"}
|
|
72
|
+
*
|
|
73
|
+
* then `session.idle` and exits **0** without ever emitting `{"type":"error"}`.
|
|
74
|
+
* Hive Mind therefore reported the run as a success with no result summary, which
|
|
75
|
+
* is the same class of undiagnosable outcome as issue #2141's `[object Object]`:
|
|
76
|
+
* the run failed, but nothing said so.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} record - a sanitized JSON record from the agent stream.
|
|
79
|
+
* @returns {string|null} readable failure text, or null when the record is not fatal.
|
|
80
|
+
*/
|
|
81
|
+
export const detectFatalAgentLogRecord = record => {
|
|
82
|
+
if (!record || typeof record !== 'object') return null;
|
|
83
|
+
if (record.type !== 'log' || record.level !== 'error') return null;
|
|
84
|
+
|
|
85
|
+
const text = firstErrorText([record.error, record.message], { fallback: '' });
|
|
86
|
+
if (!text) return null;
|
|
87
|
+
const haystack = `${stringifyErrorValue(record.error)} ${stringifyErrorValue(record.message)}`;
|
|
88
|
+
if (!FATAL_AGENT_LOG_PATTERNS.some(pattern => pattern.test(haystack))) return null;
|
|
89
|
+
|
|
90
|
+
const parts = [stringifyErrorValue(record.error), stringifyErrorValue(record.message), record.hint ? `hint: ${stringifyErrorValue(record.hint)}` : ''];
|
|
91
|
+
return parts.filter(Boolean).join(' — ');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Scan agent stdout for explicit JSON error records (issues #1201, #2119, #2141).
|
|
96
|
+
*
|
|
97
|
+
* Exported so the detection precedence can be tested directly instead of being
|
|
98
|
+
* re-implemented in tests (the old duplicate in tests/test-agent-error-detection.mjs
|
|
99
|
+
* kept the `[object Object]` bug invisible).
|
|
100
|
+
*
|
|
101
|
+
* @param {string} stdoutOutput - captured agent stdout.
|
|
102
|
+
* @returns {{detected: boolean, type?: string, match?: string, record?: object}}
|
|
103
|
+
*/
|
|
104
|
+
export const detectAgentErrorsInOutput = stdoutOutput => {
|
|
105
|
+
// Issue #2119: frame records by balanced JSON, not by newlines, so
|
|
106
|
+
// pretty-printed and concatenated records are still inspected.
|
|
107
|
+
for (const record of parseJsonRecords(stdoutOutput)) {
|
|
108
|
+
const msg = sanitizeObjectStrings(record);
|
|
109
|
+
|
|
110
|
+
// Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
|
|
111
|
+
if (msg === null || typeof msg !== 'object') continue;
|
|
112
|
+
|
|
113
|
+
// Check for explicit error message types from agent
|
|
114
|
+
if (msg.type === 'error' || msg.type === 'step_error') {
|
|
115
|
+
return { detected: true, type: 'AgentError', match: extractAgentErrorText(msg, JSON.stringify(msg)), record: msg };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { detected: false };
|
|
120
|
+
};
|
|
121
|
+
|
|
36
122
|
// Import pricing functions from claude.lib.mjs
|
|
37
123
|
// We reuse fetchModelInfo and checkModelVisionCapability to get data from models.dev API
|
|
38
124
|
const claudeLib = await import('./claude.lib.mjs');
|
|
@@ -608,6 +694,9 @@ export const executeAgentCommand = async params => {
|
|
|
608
694
|
// Issue #1276: Track successful completion events to clear error flags
|
|
609
695
|
// When agent emits session.idle or disposal events, it means it recovered and completed successfully
|
|
610
696
|
let agentCompletedSuccessfully = false;
|
|
697
|
+
// Issue #2141: a fatal startup log record (e.g. ProviderModelNotFoundError)
|
|
698
|
+
// that the agent CLI reports without any `{"type":"error"}` event.
|
|
699
|
+
let fatalLogErrorMessage = null;
|
|
611
700
|
// Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
|
|
612
701
|
// This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
|
|
613
702
|
const streamingTokenUsage = createAgentTokenUsage();
|
|
@@ -668,8 +757,23 @@ export const executeAgentCommand = async params => {
|
|
|
668
757
|
// Issue #1201: Detect error events during streaming for reliable detection
|
|
669
758
|
if (data.type === 'error' || data.type === 'step_error') {
|
|
670
759
|
streamingErrorDetected = true;
|
|
671
|
-
|
|
760
|
+
// Issue #2141: render `{name, data:{message}}` payloads as text so the
|
|
761
|
+
// published failure reason is diagnosable instead of "[object Object]".
|
|
762
|
+
streamingErrorMessage = extractAgentErrorText(data, raw);
|
|
672
763
|
await log(`⚠️ Error event detected in stream: ${streamingErrorMessage}`, { level: 'warning' });
|
|
764
|
+
// Issue #2141: keep the untouched record so the root cause survives even
|
|
765
|
+
// when the rendering above loses a field. Verbose-only to keep normal
|
|
766
|
+
// logs readable; --attach-logs then carries the full payload.
|
|
767
|
+
await log(` Raw error record: ${JSON.stringify(data)}`, { level: 'warning', verbose: true });
|
|
768
|
+
}
|
|
769
|
+
// Issue #2141: fail fast when the CLI could not even start the model.
|
|
770
|
+
if (!fatalLogErrorMessage) {
|
|
771
|
+
const fatalLogText = detectFatalAgentLogRecord(data);
|
|
772
|
+
if (fatalLogText) {
|
|
773
|
+
fatalLogErrorMessage = fatalLogText;
|
|
774
|
+
await log(`⚠️ Fatal agent log record detected: ${fatalLogText}`, { level: 'warning' });
|
|
775
|
+
await log(` Raw log record: ${JSON.stringify(data)}`, { level: 'warning', verbose: true });
|
|
776
|
+
}
|
|
673
777
|
}
|
|
674
778
|
// Issue #1263: Track text content for result summary
|
|
675
779
|
// Agent outputs text via 'text', 'assistant', or 'message' type events
|
|
@@ -759,26 +863,9 @@ export const executeAgentCommand = async params => {
|
|
|
759
863
|
// 1. Non-zero exit code (agent returns 1 on errors)
|
|
760
864
|
// 2. Explicit JSON error messages from agent (type: "error")
|
|
761
865
|
// 3. Usage limit detection (handled separately)
|
|
762
|
-
const detectAgentErrors = stdoutOutput => {
|
|
763
|
-
// Issue #2119: frame records by balanced JSON, not by newlines, so
|
|
764
|
-
// pretty-printed and concatenated records are still inspected.
|
|
765
|
-
for (const record of parseJsonRecords(stdoutOutput)) {
|
|
766
|
-
const msg = sanitizeObjectStrings(record);
|
|
767
|
-
|
|
768
|
-
// Issue #1968: ignore bare `null`/primitive records (msg.type would throw on null).
|
|
769
|
-
if (msg === null || typeof msg !== 'object') continue;
|
|
770
|
-
|
|
771
|
-
// Check for explicit error message types from agent
|
|
772
|
-
if (msg.type === 'error' || msg.type === 'step_error') {
|
|
773
|
-
return { detected: true, type: 'AgentError', match: msg.message || msg.error || JSON.stringify(msg).substring(0, 100) };
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
return { detected: false };
|
|
778
|
-
};
|
|
779
|
-
|
|
780
866
|
// Only check for JSON error messages, not pattern matching in output
|
|
781
|
-
|
|
867
|
+
// Issue #2141: the detection now renders structured payloads as text.
|
|
868
|
+
const outputError = detectAgentErrorsInOutput(fullOutput);
|
|
782
869
|
|
|
783
870
|
// Issue #1276: Clear streaming error detection if agent completed successfully
|
|
784
871
|
// When an error occurs during execution (e.g., timeout) but the agent recovers and completes,
|
|
@@ -857,6 +944,18 @@ export const executeAgentCommand = async params => {
|
|
|
857
944
|
}
|
|
858
945
|
}
|
|
859
946
|
|
|
947
|
+
// Issue #2141: agent CLI 0.25.5 exits 0 after `ProviderModelNotFoundError`
|
|
948
|
+
// without emitting an error event, so the run was published as a success
|
|
949
|
+
// with no result summary. Treat a fatal startup log record as the failure
|
|
950
|
+
// it is, but only when the session produced no work at all — a recovered
|
|
951
|
+
// error must keep exit code 0 authoritative (issue #1276).
|
|
952
|
+
if (exitCode === 0 && !outputError.detected && fatalLogErrorMessage && !lastTextContent) {
|
|
953
|
+
outputError.detected = true;
|
|
954
|
+
outputError.type = 'AgentFatalLog';
|
|
955
|
+
outputError.match = fatalLogErrorMessage;
|
|
956
|
+
await log(`\n⚠️ Agent exited 0 but never started a model: ${fatalLogErrorMessage}`, { level: 'warning' });
|
|
957
|
+
}
|
|
958
|
+
|
|
860
959
|
if (exitCode !== 0 || outputError.detected) {
|
|
861
960
|
const retryableError = classifyRetryableError(outputError.match || streamingErrorMessage || lastMessage || fullOutput);
|
|
862
961
|
if (retryableError.isRetryable) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { CANCELLED_CI_REVIEW_MARKER } from './tool-comments.lib.mjs';
|
|
15
|
+
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
15
16
|
|
|
16
17
|
const CANCELLED_OR_STALE_CONCLUSIONS = new Set(['cancelled', 'stale']);
|
|
17
18
|
|
|
@@ -122,7 +123,8 @@ const formatRunReference = run => {
|
|
|
122
123
|
|
|
123
124
|
const formatRerunFailure = failure => {
|
|
124
125
|
const runPart = formatRunReference(failure?.run);
|
|
125
|
-
|
|
126
|
+
// Issue #2141: the error may arrive as an object; never publish [object Object].
|
|
127
|
+
const error = stringifyErrorValue(failure?.error, { fallback: 'Unknown error' });
|
|
126
128
|
return `${runPart}: ${error}`;
|
|
127
129
|
};
|
|
128
130
|
|
package/src/claude.lib.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { createThinkingBlockRecovery } from './claude.thinking-block-recovery.li
|
|
|
36
36
|
import { buildMissingClaudeResultMessage, collectClaudeStreamEventFacts, getClaudeMessageContent, shouldFailClaudeStreamWithoutResult } from './claude.stream-events.lib.mjs';
|
|
37
37
|
import { formatNumber, mapModelToId, checkModelVisionCapability } from './claude.model-utils.lib.mjs';
|
|
38
38
|
import { showResumeCommand } from './claude.resume-output.lib.mjs';
|
|
39
|
+
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
39
40
|
import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
|
|
40
41
|
export { availableModels, fetchModelInfo }; // Re-export for backward compatibility
|
|
41
42
|
export { formatNumber, mapModelToId, checkModelVisionCapability };
|
|
@@ -96,7 +97,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
|
|
|
96
97
|
const jsonMatch = text.match(/\{.*"error".*\}/);
|
|
97
98
|
if (jsonMatch) {
|
|
98
99
|
const errorObj = JSON.parse(jsonMatch[0]);
|
|
99
|
-
return errorObj.error;
|
|
100
|
+
return stringifyErrorValue(errorObj.error, { fallback: jsonMatch[0] }); // Issue #2141: the `error` field is usually an object
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
} catch (e) {
|
|
@@ -936,7 +937,7 @@ export const executeClaudeCommand = async params => {
|
|
|
936
937
|
}
|
|
937
938
|
if (data.type === 'text' && data.text) lastMessage = data.text;
|
|
938
939
|
else if (data.type === 'error') {
|
|
939
|
-
lastMessage = data.error
|
|
940
|
+
lastMessage = stringifyErrorValue(data.error, { fallback: JSON.stringify(data) }); // Issue #2141: `data.error` is often an object; render it as text so the reason is never "[object Object]" and the substring checks below get a real string
|
|
940
941
|
if (lastMessage.includes('Internal server error')) isInternalServerError = true;
|
|
941
942
|
}
|
|
942
943
|
// Issue #1491: Track token usage from stream events for independent calculation
|
|
@@ -1009,7 +1010,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1009
1010
|
}
|
|
1010
1011
|
// Not JSON or parsing failed, output as-is if it's not empty
|
|
1011
1012
|
if (line.trim() && !line.includes('node:internal')) {
|
|
1012
|
-
await log(line, { stream: '
|
|
1013
|
+
await log(line, { stream: 'stdout' });
|
|
1013
1014
|
lastMessage = line;
|
|
1014
1015
|
// Issue #1015: Detect terms acceptance prompt (non-JSON "[ACTION REQUIRED]..." message)
|
|
1015
1016
|
const termsAcceptancePattern = /\[ACTION REQUIRED\].*terms|must run.*claude.*review.*terms/i;
|
|
@@ -1080,7 +1081,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1080
1081
|
}
|
|
1081
1082
|
if (progressMonitor) await progressMonitor.processStreamEvent(data, true).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
|
|
1082
1083
|
} catch {
|
|
1083
|
-
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: '
|
|
1084
|
+
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'stdout' });
|
|
1084
1085
|
}
|
|
1085
1086
|
}
|
|
1086
1087
|
if (startupTimeoutId) {
|
package/src/codex-health.lib.mjs
CHANGED
|
@@ -269,9 +269,28 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
269
269
|
addDiskEvidence('result-summary', codexJsonState?.resultSummary);
|
|
270
270
|
const diskPressureDetected = diskEvidence.length > 0;
|
|
271
271
|
|
|
272
|
+
// Issue #2140: the counts alone made the #2136 false positive unfalsifiable —
|
|
273
|
+
// "turn.started=3, turn.completed=1" was posted to the PR with nothing to say
|
|
274
|
+
// whether those starts were codex's own. Carry the evidence that decides it:
|
|
275
|
+
// the ordered lifecycle, what was discarded as echoed telemetry, and any
|
|
276
|
+
// foreign thread id that reached the protocol stream.
|
|
277
|
+
const turnLifecycle = Array.isArray(codexJsonState?.turnLifecycle) ? codexJsonState.turnLifecycle : [];
|
|
278
|
+
const telemetryEventCounts = codexJsonState?.telemetryEventCounts || {};
|
|
279
|
+
const foreignThreadIds = codexJsonState?.foreignThreadIds || [];
|
|
280
|
+
const echoedTurnStarts = telemetryEventCounts['turn.started'] || 0;
|
|
281
|
+
|
|
272
282
|
const reasons = [];
|
|
273
283
|
if (incompleteSession) {
|
|
274
284
|
reasons.push(`Codex session ended without completing its turn (turn.started=${turnStarted}, turn.completed=${turnCompleted}, turn.failed=${turnFailed}); the process exited 0 but was cut off mid-turn.`);
|
|
285
|
+
if (turnLifecycle.length > 0) {
|
|
286
|
+
reasons.push(`Turn lifecycle in order: ${turnLifecycle.join(' → ')} — the stream ends on a start, so the last turn never finished.`);
|
|
287
|
+
}
|
|
288
|
+
if (echoedTurnStarts > 0 || foreignThreadIds.length > 0) {
|
|
289
|
+
const echoParts = [];
|
|
290
|
+
if (echoedTurnStarts > 0) echoParts.push(`${echoedTurnStarts} echoed turn.started on codex stderr (excluded from the counts above)`);
|
|
291
|
+
if (foreignThreadIds.length > 0) echoParts.push(`foreign thread id(s) on the protocol stream: ${foreignThreadIds.join(', ')}`);
|
|
292
|
+
reasons.push(`Echo diagnostics (issues #2136/#2140): ${echoParts.join('; ')}.`);
|
|
293
|
+
}
|
|
275
294
|
if (diskPressureDetected) {
|
|
276
295
|
reasons.push(`Disk-exhaustion signals were present in ${diskEvidence.length} location(s) (e.g. "No space left on device") — the likely cause of the interrupted session.`);
|
|
277
296
|
}
|
|
@@ -285,6 +304,9 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
285
304
|
turnStarted,
|
|
286
305
|
turnCompleted,
|
|
287
306
|
turnFailed,
|
|
307
|
+
turnLifecycle,
|
|
308
|
+
echoedTurnStarts,
|
|
309
|
+
foreignThreadIds,
|
|
288
310
|
reasons,
|
|
289
311
|
};
|
|
290
312
|
};
|
|
@@ -305,6 +327,9 @@ export const reportCodexCompletionFailure = async ({ completionHealth, log, getR
|
|
|
305
327
|
await log(` • ${reason}`, { level: 'error' });
|
|
306
328
|
}
|
|
307
329
|
await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
|
|
330
|
+
if (completionHealth.turnLifecycle?.length) {
|
|
331
|
+
await log(` 🔁 turn lifecycle: ${completionHealth.turnLifecycle.join(' → ')}`, { verbose: true });
|
|
332
|
+
}
|
|
308
333
|
if (completionHealth.diskPressureDetected) {
|
|
309
334
|
await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
|
|
310
335
|
for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
|
package/src/codex.lib.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
|
|
|
25
25
|
import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
|
|
26
26
|
const __codexBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'codex', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
|
|
27
27
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
28
|
+
import { firstErrorText } from './error-text.lib.mjs'; // Issue #2141
|
|
28
29
|
import { createLineBuffer } from './json-stream.lib.mjs'; // Issue #2119
|
|
29
30
|
import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.mjs';
|
|
30
31
|
import { buildCodexRunDiagnostics, codexRunAlreadyFailed, describeCodexLastMessageOutcome } from './codex.run-diagnostics.lib.mjs'; // Issue #2130
|
|
@@ -43,52 +44,11 @@ import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-ca
|
|
|
43
44
|
import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
|
|
44
45
|
import Decimal from 'decimal.js-light';
|
|
45
46
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
47
|
+
import { CODEX_CACHE_READ_USAGE_PATHS, CODEX_CACHE_WRITE_USAGE_PATHS, CODEX_MODEL_DIAGNOSTIC_PATHS, CODEX_REASONING_USAGE_PATHS, CODEX_USAGE_FIELD_NAMES, createCodexTokenFieldAvailability, getFirstObservedNumber, hasAnyObservedPath, hasOwnPath } from './codex.usage-fields.lib.mjs';
|
|
46
48
|
|
|
47
|
-
const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
48
49
|
const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
|
|
49
50
|
const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
|
|
50
51
|
const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
|
|
51
|
-
const CODEX_MODEL_DIAGNOSTIC_PATHS = [
|
|
52
|
-
['model', data => data?.model],
|
|
53
|
-
['model_name', data => data?.model_name],
|
|
54
|
-
['from_model', data => data?.from_model],
|
|
55
|
-
['to_model', data => data?.to_model],
|
|
56
|
-
['message.model', data => data?.message?.model],
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
const createCodexTokenFieldAvailability = () => ({
|
|
60
|
-
inputTokens: false,
|
|
61
|
-
outputTokens: false,
|
|
62
|
-
reasoningTokens: false,
|
|
63
|
-
cacheReadTokens: false,
|
|
64
|
-
cacheWriteTokens: false,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const hasOwnPath = (object, pathName) => {
|
|
68
|
-
let cursor = object;
|
|
69
|
-
for (const part of pathName.split('.')) {
|
|
70
|
-
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
|
|
71
|
-
cursor = cursor[part];
|
|
72
|
-
}
|
|
73
|
-
return true;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
|
|
77
|
-
|
|
78
|
-
const getFirstObservedNumber = (object, pathNames) => {
|
|
79
|
-
for (const pathName of pathNames) {
|
|
80
|
-
if (!hasOwnPath(object, pathName)) continue;
|
|
81
|
-
const value = getPathValue(object, pathName);
|
|
82
|
-
return Number.isFinite(value) ? value : 0;
|
|
83
|
-
}
|
|
84
|
-
return 0;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
|
|
88
|
-
|
|
89
|
-
const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
|
|
90
|
-
const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
|
|
91
|
-
const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
92
52
|
|
|
93
53
|
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
54
|
|
|
@@ -380,6 +340,12 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
380
340
|
// completion gate can ask "did the last turn finish?" instead of comparing
|
|
381
341
|
// counts that an echoed `turn.started` can skew.
|
|
382
342
|
turnLifecycle: state.turnLifecycle || [],
|
|
343
|
+
// Issue #2140: `thread.started` records seen on the protocol stream that
|
|
344
|
+
// announce a thread id other than this session's. Codex only starts one
|
|
345
|
+
// thread per `codex exec`, so a second id is proof that something echoed
|
|
346
|
+
// another agent's protocol into ours — the one turn event that carries an
|
|
347
|
+
// identity we can check. Diagnostics only; the gate stays order-based.
|
|
348
|
+
foreignThreadIds: state.foreignThreadIds || [],
|
|
383
349
|
};
|
|
384
350
|
|
|
385
351
|
nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
|
|
@@ -432,6 +398,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
432
398
|
|
|
433
399
|
if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
|
|
434
400
|
nextState.sessionId = data.thread_id;
|
|
401
|
+
} else if (eventType === 'thread.started' && typeof data.thread_id === 'string' && data.thread_id !== nextState.sessionId) {
|
|
402
|
+
// Issue #2140: a foreign thread id on the protocol stream is echoed
|
|
403
|
+
// output, not a second codex session. Record it once so a run that ends
|
|
404
|
+
// up disputed can be settled from the log alone.
|
|
405
|
+
if (!nextState.foreignThreadIds.includes(data.thread_id)) nextState.foreignThreadIds.push(data.thread_id);
|
|
435
406
|
} else if (!nextState.sessionId && typeof data.session_id === 'string') {
|
|
436
407
|
nextState.sessionId = data.session_id;
|
|
437
408
|
}
|
|
@@ -440,20 +411,20 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
440
411
|
if (typeof getter(data) === 'string') observedModelPaths.add(pathName);
|
|
441
412
|
}
|
|
442
413
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (eventType === 'turn.failed' && typeof data.error?.message === 'string' && (data.error.message.includes('401 Unauthorized') || data.error.message.includes('401') || data.error.message.includes('Unauthorized'))) {
|
|
452
|
-
nextState.authError = true;
|
|
414
|
+
// Issue #2141: these payloads are not always strings. Requiring `typeof …
|
|
415
|
+
// === 'string'` dropped every object-shaped failure — a dropped
|
|
416
|
+
// `turn.failed` reads as a success — and interpolating one would have
|
|
417
|
+
// published "[object Object]". Render it as text instead.
|
|
418
|
+
const streamErrorText = eventType === 'error' ? firstErrorText([data.message, data.error]) : '';
|
|
419
|
+
if (streamErrorText) {
|
|
420
|
+
if (streamErrorText.includes('401') || streamErrorText.includes('Unauthorized')) nextState.authError = true;
|
|
421
|
+
nextState.streamErrors.push({ message: streamErrorText });
|
|
453
422
|
}
|
|
454
423
|
|
|
455
|
-
|
|
456
|
-
|
|
424
|
+
const turnFailureText = eventType === 'turn.failed' ? firstErrorText([data.error, data.message]) : '';
|
|
425
|
+
if (turnFailureText) {
|
|
426
|
+
if (turnFailureText.includes('401') || turnFailureText.includes('Unauthorized')) nextState.authError = true;
|
|
427
|
+
nextState.turnFailures.push({ message: turnFailureText });
|
|
457
428
|
}
|
|
458
429
|
|
|
459
430
|
if (eventType === 'turn.completed' && data.usage && typeof data.usage === 'object') {
|
|
@@ -1030,7 +1001,7 @@ export const executeCodexCommand = async params => {
|
|
|
1030
1001
|
if (chunk.type === 'stdout') {
|
|
1031
1002
|
const raw = chunk.data.toString();
|
|
1032
1003
|
if (argv.verbose) {
|
|
1033
|
-
await log(raw);
|
|
1004
|
+
await log(raw, { stream: 'stdout' });
|
|
1034
1005
|
}
|
|
1035
1006
|
lastMessage = raw;
|
|
1036
1007
|
const output = codexStdoutLines.write(raw);
|
|
@@ -97,6 +97,13 @@ export const buildCodexRunDiagnostics = ({ state = {}, exitCode = null, mappedMo
|
|
|
97
97
|
}
|
|
98
98
|
if (state.turnLifecycle?.length > 0) push(`🔁 Codex turn lifecycle: ${state.turnLifecycle.join(' → ')}`);
|
|
99
99
|
|
|
100
|
+
// Issue #2140: codex starts exactly one thread per `codex exec`, so any other
|
|
101
|
+
// thread id on the protocol stream is echoed output that leaked past the
|
|
102
|
+
// stream separation above. Always worth saying out loud.
|
|
103
|
+
if (state.foreignThreadIds?.length > 0) {
|
|
104
|
+
push(`🧬 Foreign thread IDs seen on the codex protocol stream (echoed, not codex sessions): ${state.foreignThreadIds.join(', ')}`, { level: 'warning', verbose: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
const usage = state.tokenUsage || {};
|
|
101
108
|
if (usage.stepCount > 0) {
|
|
102
109
|
push(`📈 Codex usage from turn.completed: ${usage.inputTokens.toLocaleString()} input, ${usage.cacheReadTokens.toLocaleString()} cache read, ${usage.outputTokens.toLocaleString()} output across ${usage.stepCount} turn(s)`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage-field vocabulary and JSON-path helpers for the `codex exec --json`
|
|
3
|
+
* parser.
|
|
4
|
+
*
|
|
5
|
+
* Split out of codex.lib.mjs to keep that file inside the max-lines budget
|
|
6
|
+
* (issues #1730 / #1990 / #2140). Everything here is pure data plus pure
|
|
7
|
+
* lookups: codex has renamed and re-nested its usage fields several times
|
|
8
|
+
* across releases, so the parser reads whichever spelling is *present* rather
|
|
9
|
+
* than assuming one shape, and reports what it actually observed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Every usage field name we know codex has used, for observability reporting. */
|
|
13
|
+
export const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
14
|
+
|
|
15
|
+
/** Places a codex event has been seen to name a model, in preference order. */
|
|
16
|
+
export const CODEX_MODEL_DIAGNOSTIC_PATHS = [
|
|
17
|
+
['model', data => data?.model],
|
|
18
|
+
['model_name', data => data?.model_name],
|
|
19
|
+
['from_model', data => data?.from_model],
|
|
20
|
+
['to_model', data => data?.to_model],
|
|
21
|
+
['message.model', data => data?.message?.model],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
|
|
25
|
+
export const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
|
|
26
|
+
export const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
27
|
+
|
|
28
|
+
/** Which token kinds this run has actually seen codex report. */
|
|
29
|
+
export const createCodexTokenFieldAvailability = () => ({
|
|
30
|
+
inputTokens: false,
|
|
31
|
+
outputTokens: false,
|
|
32
|
+
reasoningTokens: false,
|
|
33
|
+
cacheReadTokens: false,
|
|
34
|
+
cacheWriteTokens: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Own-property check along a dotted path — absent ≠ present-and-zero. */
|
|
38
|
+
export const hasOwnPath = (object, pathName) => {
|
|
39
|
+
let cursor = object;
|
|
40
|
+
for (const part of pathName.split('.')) {
|
|
41
|
+
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
|
|
42
|
+
cursor = cursor[part];
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
|
|
48
|
+
|
|
49
|
+
/** First path that is actually present wins; a non-finite value counts as 0. */
|
|
50
|
+
export const getFirstObservedNumber = (object, pathNames) => {
|
|
51
|
+
for (const pathName of pathNames) {
|
|
52
|
+
if (!hasOwnPath(object, pathName)) continue;
|
|
53
|
+
const value = getPathValue(object, pathName);
|
|
54
|
+
return Number.isFinite(value) ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared human-readable rendering of structured error payloads (issue #2141).
|
|
3
|
+
*
|
|
4
|
+
* Agentic CLIs publish errors as *objects*, not strings. `@link-assistant/agent`
|
|
5
|
+
* 0.25.x emits `NamedError.toObject()`:
|
|
6
|
+
*
|
|
7
|
+
* {"type":"error","error":{"name":"RetryTimeoutExceededError","data":{"message":"…"}}}
|
|
8
|
+
*
|
|
9
|
+
* Every adapter that interpolated such a payload into a template literal
|
|
10
|
+
* (`Agent reported error: ${outputError.match}`) destroyed the diagnosis and
|
|
11
|
+
* published `AGENT execution failed with Agent reported error: [object Object]`
|
|
12
|
+
* to the GitHub issue, which is what issue #2141 reported. This module turns any
|
|
13
|
+
* of those shapes into text a human can act on, and is reused by every tool
|
|
14
|
+
* adapter so the defect cannot come back in one CLI at a time.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const MAX_ERROR_TEXT_LENGTH = 2000;
|
|
18
|
+
|
|
19
|
+
/** Text that carries no diagnostic value even though it is a non-empty string. */
|
|
20
|
+
const PLACEHOLDER_ERROR_TEXTS = new Set(['[object object]', '[object error]', 'undefined', 'null', '{}', '[]']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `[object Object]` (and friends) must never be published as a failure reason:
|
|
24
|
+
* it is the symptom this module exists to remove, so callers can assert on it.
|
|
25
|
+
*/
|
|
26
|
+
export const isPlaceholderErrorText = value => {
|
|
27
|
+
if (typeof value !== 'string') return false;
|
|
28
|
+
return PLACEHOLDER_ERROR_TEXTS.has(value.trim().toLowerCase());
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const truncateErrorText = (text, maxLength) => {
|
|
32
|
+
if (typeof text !== 'string') return '';
|
|
33
|
+
if (!Number.isFinite(maxLength) || maxLength <= 0) return text;
|
|
34
|
+
if (text.length <= maxLength) return text;
|
|
35
|
+
return `${text.slice(0, maxLength)}… (truncated)`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const safeJsonStringify = value => {
|
|
39
|
+
const seen = new WeakSet();
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(value, (_key, entry) => {
|
|
42
|
+
if (entry && typeof entry === 'object') {
|
|
43
|
+
if (seen.has(entry)) return '[Circular]';
|
|
44
|
+
seen.add(entry);
|
|
45
|
+
}
|
|
46
|
+
if (typeof entry === 'bigint') return entry.toString();
|
|
47
|
+
if (typeof entry === 'function') return `[Function ${entry.name || 'anonymous'}]`;
|
|
48
|
+
return entry;
|
|
49
|
+
});
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const joinParts = parts => parts.filter(part => typeof part === 'string' && part.trim().length > 0).join(': ');
|
|
56
|
+
|
|
57
|
+
const stringifyValue = (value, depth) => {
|
|
58
|
+
if (value === null || value === undefined) return '';
|
|
59
|
+
if (typeof value === 'string') return value.trim();
|
|
60
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
|
|
61
|
+
if (typeof value === 'symbol' || typeof value === 'function') return String(value);
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
if (depth > 4) return safeJsonStringify(value) || String(value);
|
|
65
|
+
const rendered = value
|
|
66
|
+
.map(entry => stringifyValue(entry, depth + 1))
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('; ');
|
|
69
|
+
return rendered || safeJsonStringify(value) || String(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof value !== 'object') return String(value);
|
|
73
|
+
if (depth > 4) return safeJsonStringify(value) || String(value);
|
|
74
|
+
|
|
75
|
+
if (value instanceof Error) {
|
|
76
|
+
const rendered = joinParts([value.name && value.name !== 'Error' ? value.name : '', value.message]);
|
|
77
|
+
return rendered || value.name || 'Error';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `NamedError.toObject()` from @link-assistant/agent: {name, data:{message,…}}.
|
|
81
|
+
// The name alone ("RetryTimeoutExceededError") is already actionable, so keep
|
|
82
|
+
// it even when `data` carries no message.
|
|
83
|
+
const name = typeof value.name === 'string' && value.name.trim() ? value.name.trim() : null;
|
|
84
|
+
|
|
85
|
+
const nestedCandidates = [value.message, value.data, value.error, value.reason, value.cause, value.detail, value.details, value.description, value.hint, value.result, value.stderr];
|
|
86
|
+
|
|
87
|
+
for (const candidate of nestedCandidates) {
|
|
88
|
+
if (candidate === null || candidate === undefined) continue;
|
|
89
|
+
const rendered = stringifyValue(candidate, depth + 1);
|
|
90
|
+
if (!rendered || isPlaceholderErrorText(rendered)) continue;
|
|
91
|
+
// Avoid "Foo: Foo" when the nested value repeats the name.
|
|
92
|
+
if (name && rendered === name) return name;
|
|
93
|
+
return joinParts([name, rendered]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (name) return name;
|
|
97
|
+
|
|
98
|
+
const json = safeJsonStringify(value);
|
|
99
|
+
if (json && json !== '{}') return json;
|
|
100
|
+
return '';
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Render any error payload (string, Error, NamedError object, nested envelope,
|
|
105
|
+
* array of the above) as a single human-readable line.
|
|
106
|
+
*
|
|
107
|
+
* @param {unknown} value - the payload as received from the tool stream.
|
|
108
|
+
* @param {object} [options]
|
|
109
|
+
* @param {number} [options.maxLength] - truncation budget for the rendered text.
|
|
110
|
+
* @param {string} [options.fallback] - returned when nothing readable is found.
|
|
111
|
+
* @returns {string} readable text, never `[object Object]`.
|
|
112
|
+
*/
|
|
113
|
+
export const stringifyErrorValue = (value, { maxLength = MAX_ERROR_TEXT_LENGTH, fallback = '' } = {}) => {
|
|
114
|
+
const rendered = stringifyValue(value, 0);
|
|
115
|
+
if (!rendered || isPlaceholderErrorText(rendered)) return fallback;
|
|
116
|
+
return truncateErrorText(rendered, maxLength);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Pick the first readable rendering among several candidate payloads.
|
|
121
|
+
* Used where an adapter has to try `data.message`, then `data.error`, then the
|
|
122
|
+
* raw record text (the exact chain that produced `[object Object]` before).
|
|
123
|
+
*/
|
|
124
|
+
export const firstErrorText = (candidates, { maxLength = MAX_ERROR_TEXT_LENGTH, fallback = '' } = {}) => {
|
|
125
|
+
for (const candidate of Array.isArray(candidates) ? candidates : [candidates]) {
|
|
126
|
+
const rendered = stringifyErrorValue(candidate, { maxLength });
|
|
127
|
+
if (rendered) return rendered;
|
|
128
|
+
}
|
|
129
|
+
return fallback;
|
|
130
|
+
};
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
|
|
|
17
17
|
import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
|
|
18
18
|
const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
|
|
19
19
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
20
|
+
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
20
21
|
import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs';
|
|
21
22
|
import { isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
|
|
22
23
|
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
@@ -210,7 +211,8 @@ const applyGeminiJsonEvent = (event, nextState, modelId = null) => {
|
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
if (data.error) {
|
|
213
|
-
|
|
214
|
+
// Issue #2141: prefer readable text over a raw JSON dump of the payload.
|
|
215
|
+
nextState.errorMessages.push(extractGeminiTextContent(data.error) || stringifyErrorValue(data.error, { fallback: JSON.stringify(data.error) }));
|
|
214
216
|
} else if (type.toLowerCase().includes('error')) {
|
|
215
217
|
nextState.errorMessages.push(text || JSON.stringify(data));
|
|
216
218
|
}
|
|
@@ -462,7 +464,7 @@ export const executeGeminiCommand = async params => {
|
|
|
462
464
|
for await (const chunk of execCommand.stream()) {
|
|
463
465
|
if (chunk.type === 'stdout') {
|
|
464
466
|
const output = chunk.data.toString();
|
|
465
|
-
await log(output);
|
|
467
|
+
await log(output, { stream: 'stdout' });
|
|
466
468
|
allOutput += output;
|
|
467
469
|
geminiJsonState = parseGeminiJsonOutput(output, geminiJsonState, mappedModel);
|
|
468
470
|
if (geminiJsonState.sessionId) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { createCollapsible, createRawJsonSection, createRedactedRawJsonSection, escapeMarkdown, redactImageData, safeJsonStringify, truncateMiddle } from './interactive-mode.shared.lib.mjs';
|
|
4
4
|
import { INTERACTIVE_SESSION_STARTED_MARKER } from './tool-comments.lib.mjs';
|
|
5
|
+
import { firstErrorText } from './error-text.lib.mjs'; // Issue #2141
|
|
5
6
|
|
|
6
7
|
export const createCodexEventHandlers = ({ state, postComment, handleAssistantText, imageRenderer }) => {
|
|
7
8
|
const handleCodexThreadStarted = async data => {
|
|
@@ -142,7 +143,9 @@ ${createRawJsonSection(data)}`);
|
|
|
142
143
|
};
|
|
143
144
|
|
|
144
145
|
const handleCodexError = async data => {
|
|
145
|
-
|
|
146
|
+
// Issue #2141: `data.error` is often an object, so render it as text instead
|
|
147
|
+
// of letting `[object Object]` reach the GitHub comment.
|
|
148
|
+
const message = firstErrorText([data?.message, data?.error, data], { fallback: 'Unknown Codex error' });
|
|
146
149
|
await postComment(`## ❌ Codex error
|
|
147
150
|
|
|
148
151
|
${createCollapsible('View error', escapeMarkdown(message), true)}
|
package/src/lib.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ensureUseM } from './use-m-bootstrap.lib.mjs';
|
|
3
3
|
import { createCredentialStreamSanitizer, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
|
|
4
4
|
import { recordLogBytes, resetLogGrowth } from './log-growth.lib.mjs'; // issue #2135: notice a session log that is running away
|
|
5
|
+
import { isPlaceholderErrorText } from './error-text.lib.mjs'; // issue #2141: never publish "[object Object]" as a reason
|
|
5
6
|
|
|
6
7
|
export { maskToken };
|
|
7
8
|
|
|
@@ -95,10 +96,14 @@ export const getAbsoluteLogPath = async () => {
|
|
|
95
96
|
* @param {Object} options - Logging options
|
|
96
97
|
* @param {string} [options.level='info'] - Log level (info, warn, error)
|
|
97
98
|
* @param {boolean} [options.verbose=false] - Whether this is a verbose log
|
|
99
|
+
* @param {string} [options.stream] - Provenance of the message when it is raw
|
|
100
|
+
* output mirrored from a child process: 'stdout' or 'stderr'. Tags the log
|
|
101
|
+
* file lines [STDOUT]/[STDERR] (matching the process.stdout/stderr
|
|
102
|
+
* interceptor below) and routes the console write to the same stream.
|
|
98
103
|
* @returns {Promise<void>}
|
|
99
104
|
*/
|
|
100
105
|
export const log = async (message, options = {}) => {
|
|
101
|
-
const { level = 'info', verbose = false } = options;
|
|
106
|
+
const { level = 'info', verbose = false, stream = null } = options;
|
|
102
107
|
|
|
103
108
|
// Skip verbose logs unless --verbose is enabled
|
|
104
109
|
if (verbose && !global.verboseMode) {
|
|
@@ -107,12 +112,20 @@ export const log = async (message, options = {}) => {
|
|
|
107
112
|
|
|
108
113
|
const sanitizedMessage = sanitizeCredentialText(message);
|
|
109
114
|
|
|
115
|
+
// Issue #2140: mirrored child output must stay attributable to the stream it
|
|
116
|
+
// came from. Both Codex streams used to be written as plain [INFO], so a run
|
|
117
|
+
// log could not answer "did Codex emit this protocol line, or did its stderr
|
|
118
|
+
// merely echo one?" — the exact question a false completion failure hinges on.
|
|
119
|
+
// An explicit level still wins, so warnings/errors keep their own tag.
|
|
120
|
+
const mirroredStream = stream === 'stdout' || stream === 'stderr' ? stream : null;
|
|
121
|
+
const tag = mirroredStream && level === 'info' ? mirroredStream.toUpperCase() : level.toUpperCase();
|
|
122
|
+
|
|
110
123
|
// Write to file if log file is set
|
|
111
124
|
// Issue #1572: Handle multi-line messages by timestamping each line,
|
|
112
125
|
// so continuation lines don't appear without timestamps in the log file
|
|
113
126
|
if (logFile) {
|
|
114
127
|
const timestamp = new Date().toISOString();
|
|
115
|
-
const prefix = `[${timestamp}] [${
|
|
128
|
+
const prefix = `[${timestamp}] [${tag}]`;
|
|
116
129
|
const lines = sanitizedMessage.split('\n');
|
|
117
130
|
const logMessage = lines.map(line => `${prefix} ${line}`).join('\n');
|
|
118
131
|
try {
|
|
@@ -146,7 +159,10 @@ export const log = async (message, options = {}) => {
|
|
|
146
159
|
break;
|
|
147
160
|
case 'info':
|
|
148
161
|
default:
|
|
149
|
-
|
|
162
|
+
// Mirrored child stderr goes to our stderr, so piping stdout to a
|
|
163
|
+
// consumer keeps yielding only what the child wrote to stdout.
|
|
164
|
+
if (mirroredStream === 'stderr') console.error(sanitizedMessage);
|
|
165
|
+
else console.log(sanitizedMessage);
|
|
150
166
|
break;
|
|
151
167
|
}
|
|
152
168
|
} finally {
|
|
@@ -747,6 +763,9 @@ export const isMeaningfulErrorText = value => {
|
|
|
747
763
|
if (!value || typeof value !== 'string') return false;
|
|
748
764
|
const collapsed = value.replace(/\s+/g, ' ').trim();
|
|
749
765
|
if (!collapsed) return false;
|
|
766
|
+
// Issue #2141: "[object Object]" has letters, but it is the *absence* of an
|
|
767
|
+
// error message — a structured payload that was stringified by mistake.
|
|
768
|
+
if (isPlaceholderErrorText(collapsed)) return false;
|
|
750
769
|
// Require at least one Unicode letter or number; pure punctuation/brackets
|
|
751
770
|
// (e.g. "}", "{", "[]", ",") are stream fragments, not real errors.
|
|
752
771
|
return /[\p{L}\p{N}]/u.test(collapsed);
|
|
@@ -805,6 +824,13 @@ export const extractToolErrorCore = ({ toolResult } = {}) => {
|
|
|
805
824
|
// Issue #1941: reject stray fragments with no letters/digits (e.g. "}").
|
|
806
825
|
if (!isMeaningfulErrorText(rawCore)) return null;
|
|
807
826
|
|
|
827
|
+
// Issue #2141: a core that embeds "[object Object]" (e.g. "Agent reported
|
|
828
|
+
// error: [object Object]") carries no diagnosis at all. Publishing the generic
|
|
829
|
+
// phrase is more honest than publishing a stringified object, and it keeps the
|
|
830
|
+
// symptom out of GitHub comments even if a new adapter forgets to render its
|
|
831
|
+
// payload as text.
|
|
832
|
+
if (rawCore.includes('[object Object]')) return null;
|
|
833
|
+
|
|
808
834
|
// Collapse to a single clean line and strip noise.
|
|
809
835
|
const core = rawCore.replace(/\s+/g, ' ').trim();
|
|
810
836
|
return core || null;
|
package/src/opencode.lib.mjs
CHANGED
|
@@ -394,7 +394,7 @@ export const executeOpenCodeCommand = async params => {
|
|
|
394
394
|
for await (const chunk of execCommand.stream()) {
|
|
395
395
|
if (chunk.type === 'stdout') {
|
|
396
396
|
const output = chunk.data.toString();
|
|
397
|
-
await log(output);
|
|
397
|
+
await log(output, { stream: 'stdout' });
|
|
398
398
|
lastMessage = output;
|
|
399
399
|
allOutput += output;
|
|
400
400
|
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toToken
|
|
|
27
27
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
28
28
|
import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
|
|
29
29
|
import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
|
|
30
|
+
import { stringifyErrorValue } from './error-text.lib.mjs'; // Issue #2141
|
|
30
31
|
|
|
31
32
|
export const mapModelToId = model => qwenModels[model] || model;
|
|
32
33
|
|
|
@@ -41,18 +42,6 @@ const isQwenAuthError = output => {
|
|
|
41
42
|
return text.includes('401') || text.includes('unauthorized') || text.includes('authentication') || text.includes('auth') || text.includes('login') || text.includes('api key') || text.includes('oauth free tier');
|
|
42
43
|
};
|
|
43
44
|
|
|
44
|
-
const stringifyErrorValue = value => {
|
|
45
|
-
if (value === null || value === undefined) return '';
|
|
46
|
-
if (typeof value === 'string') return value;
|
|
47
|
-
if (typeof value?.message === 'string') return value.message;
|
|
48
|
-
if (typeof value?.error?.message === 'string') return value.error.message;
|
|
49
|
-
try {
|
|
50
|
-
return JSON.stringify(value);
|
|
51
|
-
} catch {
|
|
52
|
-
return String(value);
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
45
|
const getNestedValue = (object, pathParts) => {
|
|
57
46
|
let cursor = object;
|
|
58
47
|
for (const part of pathParts) {
|
|
@@ -566,7 +555,7 @@ export const executeQwenCommand = async params => {
|
|
|
566
555
|
for await (const chunk of execCommand.stream()) {
|
|
567
556
|
if (chunk.type === 'stdout') {
|
|
568
557
|
const output = chunk.data.toString();
|
|
569
|
-
await log(output);
|
|
558
|
+
await log(output, { stream: 'stdout' });
|
|
570
559
|
allOutput += output;
|
|
571
560
|
qwenState = parseQwenStreamJsonOutput(output, qwenState);
|
|
572
561
|
}
|
|
@@ -12,6 +12,14 @@ const truncate = (value, maxLength = 2000) => {
|
|
|
12
12
|
|
|
13
13
|
const fence = value => truncate(value || 'Unknown error').replaceAll('```', '` ` `');
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Issue #2141: the failure comment was the *only* record of the run — the reason
|
|
17
|
+
* said "Agent reported error: [object Object]", `--attach-logs` was off, and the
|
|
18
|
+
* session log was never published, so the root cause could not be recovered
|
|
19
|
+
* afterwards. Say what is missing and how to make the next run diagnosable.
|
|
20
|
+
*/
|
|
21
|
+
const buildLogLine = logAttachmentAttempted => (logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled, so this comment is the only surviving record of the failure. Rerun with `--attach-logs --verbose` to publish the full session log with the raw tool error records.');
|
|
22
|
+
|
|
15
23
|
const isForkDivergenceFailure = reason => {
|
|
16
24
|
const normalizedReason = String(reason || '').toLowerCase();
|
|
17
25
|
return normalizedReason.includes('fork divergence') || (normalizedReason.includes('fork') && normalizedReason.includes('non-fast-forward')) || normalizedReason.includes('force-with-lease');
|
|
@@ -124,7 +132,7 @@ export function resolvePreExitFailureNotificationTarget({ code, globalState }) {
|
|
|
124
132
|
export function buildPrePullRequestFailureComment({ reason, owner, repo, issueNumber, argv = {}, logAttachmentAttempted = false, failureActionSection = null }) {
|
|
125
133
|
const tool = argv.tool || 'claude';
|
|
126
134
|
const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
|
|
127
|
-
const logLine = logAttachmentAttempted
|
|
135
|
+
const logLine = buildLogLine(logAttachmentAttempted);
|
|
128
136
|
const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
|
|
129
137
|
|
|
130
138
|
return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
|
|
@@ -151,7 +159,7 @@ export function buildExistingPullRequestFailureComment({ reason, owner, repo, pr
|
|
|
151
159
|
const tool = argv.tool || 'claude';
|
|
152
160
|
const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
|
|
153
161
|
const issueLine = issueNumber ? `\n- **Linked issue**: #${issueNumber}` : '';
|
|
154
|
-
const logLine = logAttachmentAttempted
|
|
162
|
+
const logLine = buildLogLine(logAttachmentAttempted);
|
|
155
163
|
const actionSection = failureActionSection || buildPrePullRequestFailureActionSection(reason);
|
|
156
164
|
|
|
157
165
|
return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
|