@link-assistant/hive-mind 2.11.9 → 2.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.11.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 8c8844a: Stop treating agent CLI stderr as a JSON protocol stream (issue #2136).
8
+
9
+ `codex exec --json` writes its NDJSON protocol to stdout only; its stderr carries
10
+ OTEL tracing whose `codex.tool_result` records dump the raw stdout of every
11
+ command Codex runs. When the task itself drove another agent CLI, that dump
12
+ replayed NDJSON byte-identical to Codex's own protocol, so an echoed
13
+ `turn.started` was counted as Codex's own and the completion gate failed a run
14
+ that had actually finished — posting a "Solution Draft Failed" comment on a pull
15
+ request that was complete and later merged.
16
+
17
+ Codex now parses only stdout as protocol; protocol-shaped JSON seen on stderr is
18
+ reported separately (`🪞 Echoed protocol-shaped lines on codex stderr`) and never
19
+ affects event counts, session id, token usage or error detection. The completion
20
+ gate additionally uses the ordered turn lifecycle (`🔁 Codex turn lifecycle`)
21
+ instead of comparing counts, so a stray `turn.started` can no longer fail a
22
+ completed run while a genuinely truncated turn still does. The same stream
23
+ separation is applied to qwen (whose stderr echo could raise a false error and
24
+ whose two streams shared one line buffer), and OpenCode now reports how many JSON
25
+ records it parsed from stderr.
26
+
3
27
  ## 2.11.9
4
28
 
5
29
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.9",
3
+ "version": "2.11.10",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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 && turnCompleted + turnFailed < Math.max(turnStarted, 1);
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 = [];
package/src/codex.lib.mjs CHANGED
@@ -338,7 +338,21 @@ const upsertCodexItemError = (itemErrors, item) => {
338
338
  });
339
339
  };
340
340
 
341
- export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId = null) => {
341
+ // Issue #2136: `codex exec --json` writes its NDJSON protocol to **stdout** only.
342
+ // Its stderr carries OTEL tracing text (RUST_LOG=debug under --verbose), and each
343
+ // `codex.tool_result` record dumps the raw stdout of the command codex just ran —
344
+ // so a task driving another agent CLI replays that agent's NDJSON verbatim inside
345
+ // the trace. Feeding stderr through this parser counted the nested agent's
346
+ // `turn.started` as codex's own, and the #1990 completion gate then failed a run
347
+ // that had actually completed (see docs/case-studies/issue-2136).
348
+ //
349
+ // Fix: only stdout is trusted as protocol. Stderr is still scanned for the
350
+ // text-only diagnostics that legitimately live there (token/model diagnostics,
351
+ // the #2102 plugin-install rejection), but protocol-shaped JSON found there is
352
+ // counted into `telemetryEventCounts` and otherwise ignored — it never touches
353
+ // eventCounts, sessionId, usage or the error buckets.
354
+ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId = null, { source = 'stdout' } = {}) => {
355
+ const isProtocolStream = source === 'stdout';
342
356
  const nextState = {
343
357
  sessionId: state.sessionId || null,
344
358
  authError: state.authError || false,
@@ -359,6 +373,13 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
359
373
  pluginInstallRejections: state.pluginInstallRejections || [],
360
374
  observedUsageFieldSets: state.observedUsageFieldSets || [],
361
375
  observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
376
+ // Issue #2136: protocol-shaped JSON seen on a non-protocol stream (telemetry
377
+ // echo), kept for diagnostics only.
378
+ telemetryEventCounts: state.telemetryEventCounts || {},
379
+ // Issue #2136: ordered turn lifecycle from the protocol stream, so the
380
+ // completion gate can ask "did the last turn finish?" instead of comparing
381
+ // counts that an echoed `turn.started` can skew.
382
+ turnLifecycle: state.turnLifecycle || [],
362
383
  };
363
384
 
364
385
  nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
@@ -396,7 +417,18 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
396
417
  if (data === null || typeof data !== 'object') continue;
397
418
 
398
419
  const eventType = typeof data.type === 'string' ? data.type : 'unknown';
420
+
421
+ // Issue #2136: a protocol-shaped object on a non-protocol stream is echoed
422
+ // telemetry, never a codex event. Count it for diagnostics and move on.
423
+ if (!isProtocolStream) {
424
+ nextState.telemetryEventCounts[eventType] = (nextState.telemetryEventCounts[eventType] || 0) + 1;
425
+ continue;
426
+ }
427
+
399
428
  nextState.eventCounts[eventType] = (nextState.eventCounts[eventType] || 0) + 1;
429
+ if (eventType === 'turn.started' || eventType === 'turn.completed' || eventType === 'turn.failed') {
430
+ nextState.turnLifecycle.push(eventType);
431
+ }
400
432
 
401
433
  if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
402
434
  nextState.sessionId = data.thread_id;
@@ -983,6 +1015,8 @@ export const executeCodexCommand = async params => {
983
1015
  pluginInstallRejections: [],
984
1016
  observedUsageFieldSets: [],
985
1017
  observedModelDiagnosticPaths: [],
1018
+ telemetryEventCounts: {},
1019
+ turnLifecycle: [],
986
1020
  };
987
1021
 
988
1022
  // Issue #2119: a process chunk boundary can fall in the middle of an
@@ -1001,7 +1035,7 @@ export const executeCodexCommand = async params => {
1001
1035
  lastMessage = raw;
1002
1036
  const output = codexStdoutLines.write(raw);
1003
1037
 
1004
- codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel);
1038
+ codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel, { source: 'stdout' });
1005
1039
  await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
1006
1040
 
1007
1041
  if (interactiveHandler || progressMonitor) {
@@ -1044,7 +1078,8 @@ export const executeCodexCommand = async params => {
1044
1078
  await log(rawError, { stream: 'stderr' });
1045
1079
  }
1046
1080
  const errorOutput = codexStderrLines.write(rawError);
1047
- codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel);
1081
+ // Issue #2136: stderr is telemetry/tracing text, not the codex protocol.
1082
+ codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel, { source: 'stderr' });
1048
1083
  await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
1049
1084
  } else if (chunk.type === 'exit') {
1050
1085
  exitCode = chunk.code;
@@ -1052,9 +1087,12 @@ export const executeCodexCommand = async params => {
1052
1087
  }
1053
1088
 
1054
1089
  // Release any line that was still being assembled when the stream ended.
1055
- for (const remaining of [codexStdoutLines.flush(), codexStderrLines.flush()]) {
1090
+ for (const [source, remaining] of [
1091
+ ['stdout', codexStdoutLines.flush()],
1092
+ ['stderr', codexStderrLines.flush()],
1093
+ ]) {
1056
1094
  if (!remaining.trim()) continue;
1057
- codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel);
1095
+ codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel, { source });
1058
1096
  await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
1059
1097
  }
1060
1098
 
@@ -86,6 +86,17 @@ 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
+
89
100
  const usage = state.tokenUsage || {};
90
101
  if (usage.stepCount > 0) {
91
102
  push(`📈 Codex usage from turn.completed: ${usage.inputTokens.toLocaleString()} input, ${usage.cacheReadTokens.toLocaleString()} cache read, ${usage.outputTokens.toLocaleString()} output across ${usage.stepCount} turn(s)`);
@@ -343,9 +343,19 @@ export const executeOpenCodeCommand = async params => {
343
343
  const stdoutScanner = createJsonStreamScanner();
344
344
  const stderrScanner = createJsonStreamScanner();
345
345
 
346
- const handleOpenCodeRecords = events => {
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).
@@ -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
- export const parseQwenStreamJsonOutput = (output, state = {}) => {
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 { records, rest } = takeJsonRecords(`${nextState.buffer}${text}`);
338
- nextState.buffer = rest;
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
- if (Array.isArray(record)) {
342
- for (const item of record) addQwenEventToState(nextState, item);
343
- } else {
344
- addQwenEventToState(nextState, record);
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
 
@@ -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
- qwenState = parseQwenStreamJsonOutput(errorOutput, qwenState);
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