@link-assistant/hive-mind 2.11.9 → 2.11.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 +58 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +2 -2
- package/src/codex-health.lib.mjs +46 -1
- package/src/codex.lib.mjs +56 -48
- package/src/codex.run-diagnostics.lib.mjs +18 -0
- package/src/codex.usage-fields.lib.mjs +59 -0
- package/src/gemini.lib.mjs +1 -1
- package/src/lib.mjs +18 -3
- package/src/opencode.lib.mjs +18 -4
- package/src/qwen.lib.mjs +43 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 36e7976: Keep stream provenance on mirrored agent output and make the Codex completion
|
|
8
|
+
gate explain itself (issue #2140).
|
|
9
|
+
|
|
10
|
+
A `solve --tool codex` run that had finished its work — PR updated, all 46
|
|
11
|
+
check-runs green, `turn.completed` received — was still failed by the completion
|
|
12
|
+
gate with `turn.started=3, turn.completed=1`. Replaying the real 96k-line log
|
|
13
|
+
through the parser shows the two extra `turn.started` records arrived on Codex's
|
|
14
|
+
**stderr**, inside an OTEL `codex.tool_result` dump of a command that merely read
|
|
15
|
+
a stored NDJSON log file from disk. This is the issue #2136 defect on a binary
|
|
16
|
+
released minutes before that fix shipped; current builds already gate correctly.
|
|
17
|
+
Three residual gaps remain, and this change closes them:
|
|
18
|
+
|
|
19
|
+
- `log()` accepted an `options.stream` hint and silently discarded it, so every
|
|
20
|
+
agent CLI's mirrored output — both streams, all five tools — was written as
|
|
21
|
+
`[INFO]` on our stdout. It is now tagged `[STDOUT]` / `[STDERR]`, matching the
|
|
22
|
+
tags the stdio interceptor already uses, and mirrored stderr goes to our
|
|
23
|
+
stderr so piping stdout yields only what the child wrote there. An explicit
|
|
24
|
+
`level` still wins, and callers that pass no stream are unchanged.
|
|
25
|
+
- `codex exec` starts exactly one thread, so a `thread.started` on the protocol
|
|
26
|
+
stream announcing a different `thread_id` is proof of echoed output. Those ids
|
|
27
|
+
are now collected and reported (`🧬 Foreign thread IDs seen on the codex
|
|
28
|
+
protocol stream`).
|
|
29
|
+
- The completion-failure reason carried counts and nothing else, which made a
|
|
30
|
+
false positive impossible to refute from the posted comment. It now also
|
|
31
|
+
states the ordered turn lifecycle, how many `turn.started` records were
|
|
32
|
+
discarded as echoed telemetry, and any foreign thread id seen.
|
|
33
|
+
|
|
34
|
+
No gate outcome changes: a genuinely truncated turn still fails, and a completed
|
|
35
|
+
run with echoed events still passes.
|
|
36
|
+
|
|
37
|
+
## 2.11.10
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- 8c8844a: Stop treating agent CLI stderr as a JSON protocol stream (issue #2136).
|
|
42
|
+
|
|
43
|
+
`codex exec --json` writes its NDJSON protocol to stdout only; its stderr carries
|
|
44
|
+
OTEL tracing whose `codex.tool_result` records dump the raw stdout of every
|
|
45
|
+
command Codex runs. When the task itself drove another agent CLI, that dump
|
|
46
|
+
replayed NDJSON byte-identical to Codex's own protocol, so an echoed
|
|
47
|
+
`turn.started` was counted as Codex's own and the completion gate failed a run
|
|
48
|
+
that had actually finished — posting a "Solution Draft Failed" comment on a pull
|
|
49
|
+
request that was complete and later merged.
|
|
50
|
+
|
|
51
|
+
Codex now parses only stdout as protocol; protocol-shaped JSON seen on stderr is
|
|
52
|
+
reported separately (`🪞 Echoed protocol-shaped lines on codex stderr`) and never
|
|
53
|
+
affects event counts, session id, token usage or error detection. The completion
|
|
54
|
+
gate additionally uses the ordered turn lifecycle (`🔁 Codex turn lifecycle`)
|
|
55
|
+
instead of comparing counts, so a stray `turn.started` can no longer fail a
|
|
56
|
+
completed run while a genuinely truncated turn still does. The same stream
|
|
57
|
+
separation is applied to qwen (whose stderr echo could raise a false error and
|
|
58
|
+
whose two streams shared one line buffer), and OpenCode now reports how many JSON
|
|
59
|
+
records it parsed from stderr.
|
|
60
|
+
|
|
3
61
|
## 2.11.9
|
|
4
62
|
|
|
5
63
|
### Patch Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -1009,7 +1009,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1009
1009
|
}
|
|
1010
1010
|
// Not JSON or parsing failed, output as-is if it's not empty
|
|
1011
1011
|
if (line.trim() && !line.includes('node:internal')) {
|
|
1012
|
-
await log(line, { stream: '
|
|
1012
|
+
await log(line, { stream: 'stdout' });
|
|
1013
1013
|
lastMessage = line;
|
|
1014
1014
|
// Issue #1015: Detect terms acceptance prompt (non-JSON "[ACTION REQUIRED]..." message)
|
|
1015
1015
|
const termsAcceptancePattern = /\[ACTION REQUIRED\].*terms|must run.*claude.*review.*terms/i;
|
|
@@ -1080,7 +1080,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1080
1080
|
}
|
|
1081
1081
|
if (progressMonitor) await progressMonitor.processStreamEvent(data, true).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
|
|
1082
1082
|
} catch {
|
|
1083
|
-
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: '
|
|
1083
|
+
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'stdout' });
|
|
1084
1084
|
}
|
|
1085
1085
|
}
|
|
1086
1086
|
if (startupTimeoutId) {
|
package/src/codex-health.lib.mjs
CHANGED
|
@@ -218,6 +218,26 @@ export const getCodexPluginProvisioningHealth = (codexJsonState, { capabilityPre
|
|
|
218
218
|
// both observed in the captured runs at exit_code 0) would be wrongly failed.
|
|
219
219
|
// Disk pressure is surfaced only as supporting *diagnostics* explaining why a
|
|
220
220
|
// session was likely cut off, never as the sole reason to fail a completed turn.
|
|
221
|
+
// Issue #2136: the count comparison above is only sound when every counted
|
|
222
|
+
// `turn.started` is codex's own. It is not: under `--verbose` codex's stderr
|
|
223
|
+
// carries OTEL records that dump the raw stdout of each command it ran, so a task
|
|
224
|
+
// that drives another agent CLI replays that agent's `turn.started` into our
|
|
225
|
+
// stream. One echoed line was enough to make turn.started=2 vs turn.completed=1
|
|
226
|
+
// and fail a run that had finished successfully (formal-ai PR #913 was open with
|
|
227
|
+
// green CI). codex.lib.mjs now keeps stderr out of the protocol counters, and the
|
|
228
|
+
// gate itself asks the order-aware question — "was the LAST lifecycle event a
|
|
229
|
+
// start?" — so a stray extra `turn.started` from any future echo path can no
|
|
230
|
+
// longer flip a completed session to failed, while a genuine cut-off mid-turn
|
|
231
|
+
// (the #1990 shape: the stream ends on `turn.started`) still fails.
|
|
232
|
+
const isIncompleteTurnLifecycle = (turnLifecycle, { turnStarted, turnCompleted, turnFailed }) => {
|
|
233
|
+
if (!Array.isArray(turnLifecycle) || turnLifecycle.length === 0) {
|
|
234
|
+
// Callers that hand-build a state without an ordered lifecycle keep the
|
|
235
|
+
// original count rule.
|
|
236
|
+
return turnCompleted + turnFailed < Math.max(turnStarted, 1);
|
|
237
|
+
}
|
|
238
|
+
return turnLifecycle.at(-1) === 'turn.started';
|
|
239
|
+
};
|
|
240
|
+
|
|
221
241
|
export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } = {}) => {
|
|
222
242
|
const eventCounts = codexJsonState?.eventCounts || {};
|
|
223
243
|
const turnStarted = eventCounts['turn.started'] || 0;
|
|
@@ -232,7 +252,7 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
232
252
|
|
|
233
253
|
// A started turn that never completed or failed = the process was cut off
|
|
234
254
|
// mid-turn (OOM / disk-full / container teardown) even though it exited 0.
|
|
235
|
-
const incompleteSession = hadActivity &&
|
|
255
|
+
const incompleteSession = hadActivity && isIncompleteTurnLifecycle(codexJsonState?.turnLifecycle, { turnStarted, turnCompleted, turnFailed });
|
|
236
256
|
|
|
237
257
|
// Diagnostic-only disk-pressure hints (never an independent failure gate).
|
|
238
258
|
const diskEvidence = [];
|
|
@@ -249,9 +269,28 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
249
269
|
addDiskEvidence('result-summary', codexJsonState?.resultSummary);
|
|
250
270
|
const diskPressureDetected = diskEvidence.length > 0;
|
|
251
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
|
+
|
|
252
282
|
const reasons = [];
|
|
253
283
|
if (incompleteSession) {
|
|
254
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
|
+
}
|
|
255
294
|
if (diskPressureDetected) {
|
|
256
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.`);
|
|
257
296
|
}
|
|
@@ -265,6 +304,9 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
265
304
|
turnStarted,
|
|
266
305
|
turnCompleted,
|
|
267
306
|
turnFailed,
|
|
307
|
+
turnLifecycle,
|
|
308
|
+
echoedTurnStarts,
|
|
309
|
+
foreignThreadIds,
|
|
268
310
|
reasons,
|
|
269
311
|
};
|
|
270
312
|
};
|
|
@@ -285,6 +327,9 @@ export const reportCodexCompletionFailure = async ({ completionHealth, log, getR
|
|
|
285
327
|
await log(` • ${reason}`, { level: 'error' });
|
|
286
328
|
}
|
|
287
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
|
+
}
|
|
288
333
|
if (completionHealth.diskPressureDetected) {
|
|
289
334
|
await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
|
|
290
335
|
for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
|
package/src/codex.lib.mjs
CHANGED
|
@@ -43,52 +43,11 @@ import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-ca
|
|
|
43
43
|
import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
|
|
44
44
|
import Decimal from 'decimal.js-light';
|
|
45
45
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
46
|
+
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
47
|
|
|
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
48
|
const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
|
|
49
49
|
const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
|
|
50
50
|
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
51
|
|
|
93
52
|
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
53
|
|
|
@@ -338,7 +297,21 @@ const upsertCodexItemError = (itemErrors, item) => {
|
|
|
338
297
|
});
|
|
339
298
|
};
|
|
340
299
|
|
|
341
|
-
|
|
300
|
+
// Issue #2136: `codex exec --json` writes its NDJSON protocol to **stdout** only.
|
|
301
|
+
// Its stderr carries OTEL tracing text (RUST_LOG=debug under --verbose), and each
|
|
302
|
+
// `codex.tool_result` record dumps the raw stdout of the command codex just ran —
|
|
303
|
+
// so a task driving another agent CLI replays that agent's NDJSON verbatim inside
|
|
304
|
+
// the trace. Feeding stderr through this parser counted the nested agent's
|
|
305
|
+
// `turn.started` as codex's own, and the #1990 completion gate then failed a run
|
|
306
|
+
// that had actually completed (see docs/case-studies/issue-2136).
|
|
307
|
+
//
|
|
308
|
+
// Fix: only stdout is trusted as protocol. Stderr is still scanned for the
|
|
309
|
+
// text-only diagnostics that legitimately live there (token/model diagnostics,
|
|
310
|
+
// the #2102 plugin-install rejection), but protocol-shaped JSON found there is
|
|
311
|
+
// counted into `telemetryEventCounts` and otherwise ignored — it never touches
|
|
312
|
+
// eventCounts, sessionId, usage or the error buckets.
|
|
313
|
+
export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId = null, { source = 'stdout' } = {}) => {
|
|
314
|
+
const isProtocolStream = source === 'stdout';
|
|
342
315
|
const nextState = {
|
|
343
316
|
sessionId: state.sessionId || null,
|
|
344
317
|
authError: state.authError || false,
|
|
@@ -359,6 +332,19 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
359
332
|
pluginInstallRejections: state.pluginInstallRejections || [],
|
|
360
333
|
observedUsageFieldSets: state.observedUsageFieldSets || [],
|
|
361
334
|
observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
|
|
335
|
+
// Issue #2136: protocol-shaped JSON seen on a non-protocol stream (telemetry
|
|
336
|
+
// echo), kept for diagnostics only.
|
|
337
|
+
telemetryEventCounts: state.telemetryEventCounts || {},
|
|
338
|
+
// Issue #2136: ordered turn lifecycle from the protocol stream, so the
|
|
339
|
+
// completion gate can ask "did the last turn finish?" instead of comparing
|
|
340
|
+
// counts that an echoed `turn.started` can skew.
|
|
341
|
+
turnLifecycle: state.turnLifecycle || [],
|
|
342
|
+
// Issue #2140: `thread.started` records seen on the protocol stream that
|
|
343
|
+
// announce a thread id other than this session's. Codex only starts one
|
|
344
|
+
// thread per `codex exec`, so a second id is proof that something echoed
|
|
345
|
+
// another agent's protocol into ours — the one turn event that carries an
|
|
346
|
+
// identity we can check. Diagnostics only; the gate stays order-based.
|
|
347
|
+
foreignThreadIds: state.foreignThreadIds || [],
|
|
362
348
|
};
|
|
363
349
|
|
|
364
350
|
nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
|
|
@@ -396,10 +382,26 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
396
382
|
if (data === null || typeof data !== 'object') continue;
|
|
397
383
|
|
|
398
384
|
const eventType = typeof data.type === 'string' ? data.type : 'unknown';
|
|
385
|
+
|
|
386
|
+
// Issue #2136: a protocol-shaped object on a non-protocol stream is echoed
|
|
387
|
+
// telemetry, never a codex event. Count it for diagnostics and move on.
|
|
388
|
+
if (!isProtocolStream) {
|
|
389
|
+
nextState.telemetryEventCounts[eventType] = (nextState.telemetryEventCounts[eventType] || 0) + 1;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
399
393
|
nextState.eventCounts[eventType] = (nextState.eventCounts[eventType] || 0) + 1;
|
|
394
|
+
if (eventType === 'turn.started' || eventType === 'turn.completed' || eventType === 'turn.failed') {
|
|
395
|
+
nextState.turnLifecycle.push(eventType);
|
|
396
|
+
}
|
|
400
397
|
|
|
401
398
|
if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
|
|
402
399
|
nextState.sessionId = data.thread_id;
|
|
400
|
+
} else if (eventType === 'thread.started' && typeof data.thread_id === 'string' && data.thread_id !== nextState.sessionId) {
|
|
401
|
+
// Issue #2140: a foreign thread id on the protocol stream is echoed
|
|
402
|
+
// output, not a second codex session. Record it once so a run that ends
|
|
403
|
+
// up disputed can be settled from the log alone.
|
|
404
|
+
if (!nextState.foreignThreadIds.includes(data.thread_id)) nextState.foreignThreadIds.push(data.thread_id);
|
|
403
405
|
} else if (!nextState.sessionId && typeof data.session_id === 'string') {
|
|
404
406
|
nextState.sessionId = data.session_id;
|
|
405
407
|
}
|
|
@@ -983,6 +985,8 @@ export const executeCodexCommand = async params => {
|
|
|
983
985
|
pluginInstallRejections: [],
|
|
984
986
|
observedUsageFieldSets: [],
|
|
985
987
|
observedModelDiagnosticPaths: [],
|
|
988
|
+
telemetryEventCounts: {},
|
|
989
|
+
turnLifecycle: [],
|
|
986
990
|
};
|
|
987
991
|
|
|
988
992
|
// Issue #2119: a process chunk boundary can fall in the middle of an
|
|
@@ -996,12 +1000,12 @@ export const executeCodexCommand = async params => {
|
|
|
996
1000
|
if (chunk.type === 'stdout') {
|
|
997
1001
|
const raw = chunk.data.toString();
|
|
998
1002
|
if (argv.verbose) {
|
|
999
|
-
await log(raw);
|
|
1003
|
+
await log(raw, { stream: 'stdout' });
|
|
1000
1004
|
}
|
|
1001
1005
|
lastMessage = raw;
|
|
1002
1006
|
const output = codexStdoutLines.write(raw);
|
|
1003
1007
|
|
|
1004
|
-
codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel);
|
|
1008
|
+
codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel, { source: 'stdout' });
|
|
1005
1009
|
await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
|
|
1006
1010
|
|
|
1007
1011
|
if (interactiveHandler || progressMonitor) {
|
|
@@ -1044,7 +1048,8 @@ export const executeCodexCommand = async params => {
|
|
|
1044
1048
|
await log(rawError, { stream: 'stderr' });
|
|
1045
1049
|
}
|
|
1046
1050
|
const errorOutput = codexStderrLines.write(rawError);
|
|
1047
|
-
|
|
1051
|
+
// Issue #2136: stderr is telemetry/tracing text, not the codex protocol.
|
|
1052
|
+
codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel, { source: 'stderr' });
|
|
1048
1053
|
await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
|
|
1049
1054
|
} else if (chunk.type === 'exit') {
|
|
1050
1055
|
exitCode = chunk.code;
|
|
@@ -1052,9 +1057,12 @@ export const executeCodexCommand = async params => {
|
|
|
1052
1057
|
}
|
|
1053
1058
|
|
|
1054
1059
|
// Release any line that was still being assembled when the stream ended.
|
|
1055
|
-
for (const remaining of [
|
|
1060
|
+
for (const [source, remaining] of [
|
|
1061
|
+
['stdout', codexStdoutLines.flush()],
|
|
1062
|
+
['stderr', codexStderrLines.flush()],
|
|
1063
|
+
]) {
|
|
1056
1064
|
if (!remaining.trim()) continue;
|
|
1057
|
-
codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel);
|
|
1065
|
+
codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel, { source });
|
|
1058
1066
|
await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
|
|
1059
1067
|
}
|
|
1060
1068
|
|
|
@@ -86,6 +86,24 @@ export const buildCodexRunDiagnostics = ({ state = {}, exitCode = null, mappedMo
|
|
|
86
86
|
if (Object.keys(state.eventCounts || {}).length > 0) push(`📊 Codex JSON events: ${counts(state.eventCounts)}`);
|
|
87
87
|
if (Object.keys(state.itemTypeCounts || {}).length > 0) push(`📦 Codex item types: ${counts(state.itemTypeCounts)}`);
|
|
88
88
|
|
|
89
|
+
// Issue #2136: protocol-shaped JSON that arrived on codex's stderr (OTEL
|
|
90
|
+
// `codex.tool_result` records replay the raw stdout of every command codex
|
|
91
|
+
// runs, so a task driving another agent CLI replays that agent's NDJSON). These
|
|
92
|
+
// lines are deliberately NOT counted as codex events; surfacing them keeps the
|
|
93
|
+
// discrepancy between "events codex emitted" and "protocol lines seen in the
|
|
94
|
+
// log" explainable when a run is investigated after the fact.
|
|
95
|
+
if (Object.keys(state.telemetryEventCounts || {}).length > 0) {
|
|
96
|
+
push(`🪞 Echoed protocol-shaped lines on codex stderr (ignored, not codex events): ${counts(state.telemetryEventCounts)}`);
|
|
97
|
+
}
|
|
98
|
+
if (state.turnLifecycle?.length > 0) push(`🔁 Codex turn lifecycle: ${state.turnLifecycle.join(' → ')}`);
|
|
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
|
+
|
|
89
107
|
const usage = state.tokenUsage || {};
|
|
90
108
|
if (usage.stepCount > 0) {
|
|
91
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));
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -462,7 +462,7 @@ export const executeGeminiCommand = async params => {
|
|
|
462
462
|
for await (const chunk of execCommand.stream()) {
|
|
463
463
|
if (chunk.type === 'stdout') {
|
|
464
464
|
const output = chunk.data.toString();
|
|
465
|
-
await log(output);
|
|
465
|
+
await log(output, { stream: 'stdout' });
|
|
466
466
|
allOutput += output;
|
|
467
467
|
geminiJsonState = parseGeminiJsonOutput(output, geminiJsonState, mappedModel);
|
|
468
468
|
if (geminiJsonState.sessionId) {
|
package/src/lib.mjs
CHANGED
|
@@ -95,10 +95,14 @@ export const getAbsoluteLogPath = async () => {
|
|
|
95
95
|
* @param {Object} options - Logging options
|
|
96
96
|
* @param {string} [options.level='info'] - Log level (info, warn, error)
|
|
97
97
|
* @param {boolean} [options.verbose=false] - Whether this is a verbose log
|
|
98
|
+
* @param {string} [options.stream] - Provenance of the message when it is raw
|
|
99
|
+
* output mirrored from a child process: 'stdout' or 'stderr'. Tags the log
|
|
100
|
+
* file lines [STDOUT]/[STDERR] (matching the process.stdout/stderr
|
|
101
|
+
* interceptor below) and routes the console write to the same stream.
|
|
98
102
|
* @returns {Promise<void>}
|
|
99
103
|
*/
|
|
100
104
|
export const log = async (message, options = {}) => {
|
|
101
|
-
const { level = 'info', verbose = false } = options;
|
|
105
|
+
const { level = 'info', verbose = false, stream = null } = options;
|
|
102
106
|
|
|
103
107
|
// Skip verbose logs unless --verbose is enabled
|
|
104
108
|
if (verbose && !global.verboseMode) {
|
|
@@ -107,12 +111,20 @@ export const log = async (message, options = {}) => {
|
|
|
107
111
|
|
|
108
112
|
const sanitizedMessage = sanitizeCredentialText(message);
|
|
109
113
|
|
|
114
|
+
// Issue #2140: mirrored child output must stay attributable to the stream it
|
|
115
|
+
// came from. Both Codex streams used to be written as plain [INFO], so a run
|
|
116
|
+
// log could not answer "did Codex emit this protocol line, or did its stderr
|
|
117
|
+
// merely echo one?" — the exact question a false completion failure hinges on.
|
|
118
|
+
// An explicit level still wins, so warnings/errors keep their own tag.
|
|
119
|
+
const mirroredStream = stream === 'stdout' || stream === 'stderr' ? stream : null;
|
|
120
|
+
const tag = mirroredStream && level === 'info' ? mirroredStream.toUpperCase() : level.toUpperCase();
|
|
121
|
+
|
|
110
122
|
// Write to file if log file is set
|
|
111
123
|
// Issue #1572: Handle multi-line messages by timestamping each line,
|
|
112
124
|
// so continuation lines don't appear without timestamps in the log file
|
|
113
125
|
if (logFile) {
|
|
114
126
|
const timestamp = new Date().toISOString();
|
|
115
|
-
const prefix = `[${timestamp}] [${
|
|
127
|
+
const prefix = `[${timestamp}] [${tag}]`;
|
|
116
128
|
const lines = sanitizedMessage.split('\n');
|
|
117
129
|
const logMessage = lines.map(line => `${prefix} ${line}`).join('\n');
|
|
118
130
|
try {
|
|
@@ -146,7 +158,10 @@ export const log = async (message, options = {}) => {
|
|
|
146
158
|
break;
|
|
147
159
|
case 'info':
|
|
148
160
|
default:
|
|
149
|
-
|
|
161
|
+
// Mirrored child stderr goes to our stderr, so piping stdout to a
|
|
162
|
+
// consumer keeps yielding only what the child wrote to stdout.
|
|
163
|
+
if (mirroredStream === 'stderr') console.error(sanitizedMessage);
|
|
164
|
+
else console.log(sanitizedMessage);
|
|
150
165
|
break;
|
|
151
166
|
}
|
|
152
167
|
} finally {
|
package/src/opencode.lib.mjs
CHANGED
|
@@ -343,9 +343,19 @@ export const executeOpenCodeCommand = async params => {
|
|
|
343
343
|
const stdoutScanner = createJsonStreamScanner();
|
|
344
344
|
const stderrScanner = createJsonStreamScanner();
|
|
345
345
|
|
|
346
|
-
|
|
346
|
+
// Issue #2136: count the JSON records that arrive on stderr. OpenCode has
|
|
347
|
+
// no terminal-event completion gate (success is decided by the exit code),
|
|
348
|
+
// so unlike codex and qwen these records cannot fail a healthy run — but
|
|
349
|
+
// they do feed `lastTextContent` and token usage, and a CLI that echoes a
|
|
350
|
+
// nested agent's stream would silently skew both. Issue #1263 added stderr
|
|
351
|
+
// parsing deliberately (some OpenCode-derived CLIs emit their records
|
|
352
|
+
// there), so the behaviour is kept and only made visible.
|
|
353
|
+
let stderrJsonRecordCount = 0;
|
|
354
|
+
|
|
355
|
+
const handleOpenCodeRecords = (events, { source = 'stdout' } = {}) => {
|
|
347
356
|
for (const event of events) {
|
|
348
357
|
if (event.type !== 'json') continue;
|
|
358
|
+
if (source === 'stderr') stderrJsonRecordCount++;
|
|
349
359
|
const data = sanitizeObjectStrings(event.value);
|
|
350
360
|
// Issue #1968: a bare `null`/primitive record must not abort the
|
|
351
361
|
// rest of the chunk (data.type access would throw on null).
|
|
@@ -384,7 +394,7 @@ export const executeOpenCodeCommand = async params => {
|
|
|
384
394
|
for await (const chunk of execCommand.stream()) {
|
|
385
395
|
if (chunk.type === 'stdout') {
|
|
386
396
|
const output = chunk.data.toString();
|
|
387
|
-
await log(output);
|
|
397
|
+
await log(output, { stream: 'stdout' });
|
|
388
398
|
lastMessage = output;
|
|
389
399
|
allOutput += output;
|
|
390
400
|
|
|
@@ -399,7 +409,7 @@ export const executeOpenCodeCommand = async params => {
|
|
|
399
409
|
allOutput += errorOutput;
|
|
400
410
|
|
|
401
411
|
// Issue #1263: Also parse stderr for text content
|
|
402
|
-
handleOpenCodeRecords(stderrScanner.write(errorOutput));
|
|
412
|
+
handleOpenCodeRecords(stderrScanner.write(errorOutput), { source: 'stderr' });
|
|
403
413
|
}
|
|
404
414
|
} else if (chunk.type === 'exit') {
|
|
405
415
|
exitCode = chunk.code;
|
|
@@ -408,7 +418,11 @@ export const executeOpenCodeCommand = async params => {
|
|
|
408
418
|
|
|
409
419
|
// Release any record that was still being assembled when the stream ended.
|
|
410
420
|
handleOpenCodeRecords(stdoutScanner.flush());
|
|
411
|
-
handleOpenCodeRecords(stderrScanner.flush());
|
|
421
|
+
handleOpenCodeRecords(stderrScanner.flush(), { source: 'stderr' });
|
|
422
|
+
|
|
423
|
+
if (stderrJsonRecordCount > 0) {
|
|
424
|
+
await log(`🪞 JSON records parsed from OpenCode stderr: ${stderrJsonRecordCount} (issue #2136: stderr is not a protocol stream — check these before trusting usage/summary)`, { verbose: true });
|
|
425
|
+
}
|
|
412
426
|
|
|
413
427
|
// Clean up the opencode.json config file to avoid polluting the repository
|
|
414
428
|
try {
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -143,6 +143,10 @@ const extractTextFragments = value => {
|
|
|
143
143
|
|
|
144
144
|
const createQwenParserState = state => ({
|
|
145
145
|
buffer: state?.buffer || '',
|
|
146
|
+
// Issue #2136: stderr is framed independently of stdout — mixing the two into
|
|
147
|
+
// one buffer would splice half of a stdout record onto a stderr line.
|
|
148
|
+
telemetryBuffer: state?.telemetryBuffer || '',
|
|
149
|
+
telemetryEventCounts: { ...(state?.telemetryEventCounts || {}) },
|
|
146
150
|
plainText: state?.plainText || '',
|
|
147
151
|
parsedEvents: Array.isArray(state?.parsedEvents) ? [...state.parsedEvents] : [],
|
|
148
152
|
eventCounts: { ...(state?.eventCounts || {}) },
|
|
@@ -324,24 +328,45 @@ const addQwenEventToState = (state, rawEvent) => {
|
|
|
324
328
|
applyQwenUsageToState(state, event);
|
|
325
329
|
};
|
|
326
330
|
|
|
327
|
-
|
|
331
|
+
// Issue #2136: qwen-code writes its stream-json protocol to stdout; stderr is
|
|
332
|
+
// human/diagnostic text. Feeding stderr through the protocol parser made every
|
|
333
|
+
// JSON object a CLI happened to print there — including output a task's own
|
|
334
|
+
// commands echoed back — a genuine qwen event, which could invent an `errors`
|
|
335
|
+
// entry (an instant run failure), hijack the session id, add phantom token usage
|
|
336
|
+
// or satisfy the #1990 terminal-event gate. Codex was bitten by exactly this
|
|
337
|
+
// (see docs/case-studies/issue-2136), so qwen now marks non-stdout records as
|
|
338
|
+
// telemetry: they are counted for diagnostics and otherwise ignored. Plain-text
|
|
339
|
+
// stderr signals are unaffected — auth/usage-limit/retry classification still
|
|
340
|
+
// reads the raw combined output.
|
|
341
|
+
export const parseQwenStreamJsonOutput = (output, state = {}, { source = 'stdout' } = {}) => {
|
|
328
342
|
const nextState = createQwenParserState(state);
|
|
329
343
|
const text = output?.toString?.() ?? String(output || '');
|
|
330
344
|
nextState.plainText += text;
|
|
331
345
|
|
|
346
|
+
const isProtocolStream = source === 'stdout';
|
|
347
|
+
|
|
332
348
|
// Issue #2119: frame the stream by balanced JSON values instead of by lines.
|
|
333
349
|
// `formal-ai with qwen` emits pretty-printed, multi-line records, so every
|
|
334
350
|
// line failed to parse and every event - including the token usage - was
|
|
335
351
|
// dropped. Scanning for balanced values also covers records concatenated
|
|
336
352
|
// without a separator and records split across two process chunks.
|
|
337
|
-
const
|
|
338
|
-
|
|
353
|
+
const pendingBuffer = isProtocolStream ? nextState.buffer : nextState.telemetryBuffer;
|
|
354
|
+
const { records, rest } = takeJsonRecords(`${pendingBuffer}${text}`);
|
|
355
|
+
if (isProtocolStream) {
|
|
356
|
+
nextState.buffer = rest;
|
|
357
|
+
} else {
|
|
358
|
+
nextState.telemetryBuffer = rest;
|
|
359
|
+
}
|
|
339
360
|
|
|
340
361
|
for (const record of records) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
362
|
+
const items = Array.isArray(record) ? record : [record];
|
|
363
|
+
for (const item of items) {
|
|
364
|
+
if (isProtocolStream) {
|
|
365
|
+
addQwenEventToState(nextState, item);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const eventType = item?.type || item?.event || 'unknown';
|
|
369
|
+
nextState.telemetryEventCounts[eventType] = (nextState.telemetryEventCounts[eventType] || 0) + 1;
|
|
345
370
|
}
|
|
346
371
|
}
|
|
347
372
|
|
|
@@ -541,7 +566,7 @@ export const executeQwenCommand = async params => {
|
|
|
541
566
|
for await (const chunk of execCommand.stream()) {
|
|
542
567
|
if (chunk.type === 'stdout') {
|
|
543
568
|
const output = chunk.data.toString();
|
|
544
|
-
await log(output);
|
|
569
|
+
await log(output, { stream: 'stdout' });
|
|
545
570
|
allOutput += output;
|
|
546
571
|
qwenState = parseQwenStreamJsonOutput(output, qwenState);
|
|
547
572
|
}
|
|
@@ -551,7 +576,8 @@ export const executeQwenCommand = async params => {
|
|
|
551
576
|
if (errorOutput) {
|
|
552
577
|
await log(errorOutput, { stream: 'stderr' });
|
|
553
578
|
allOutput += errorOutput;
|
|
554
|
-
|
|
579
|
+
// Issue #2136: stderr is diagnostics, not the qwen protocol stream.
|
|
580
|
+
qwenState = parseQwenStreamJsonOutput(errorOutput, qwenState, { source: 'stderr' });
|
|
555
581
|
}
|
|
556
582
|
} else if (chunk.type === 'exit') {
|
|
557
583
|
exitCode = chunk.code;
|
|
@@ -562,6 +588,14 @@ export const executeQwenCommand = async params => {
|
|
|
562
588
|
qwenState = parseQwenStreamJsonOutput(`${qwenState.buffer}\n`, { ...qwenState, buffer: '' });
|
|
563
589
|
}
|
|
564
590
|
|
|
591
|
+
// Issue #2136: make the ignored non-protocol JSON visible, so a future
|
|
592
|
+
// investigation can tell "qwen emitted no result event" apart from "the
|
|
593
|
+
// records in the log came from stderr and were correctly ignored".
|
|
594
|
+
const qwenTelemetryTypes = Object.entries(qwenState.telemetryEventCounts || {});
|
|
595
|
+
if (qwenTelemetryTypes.length > 0) {
|
|
596
|
+
await log(`🪞 JSON records on qwen stderr (ignored, not protocol events): ${qwenTelemetryTypes.map(([type, count]) => `${type}=${count}`).join(', ')}`, { verbose: true });
|
|
597
|
+
}
|
|
598
|
+
|
|
565
599
|
const sessionId = qwenState.sessionId || null;
|
|
566
600
|
const resultSummary = qwenState.lastTextContent || null;
|
|
567
601
|
const errorMessage = qwenState.errors
|