@link-assistant/hive-mind 2.11.10 → 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 CHANGED
@@ -1,5 +1,39 @@
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
+
3
37
  ## 2.11.10
4
38
 
5
39
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.10",
3
+ "version": "2.11.11",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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: 'raw' });
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: 'raw' });
1083
+ if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'stdout' });
1084
1084
  }
1085
1085
  }
1086
1086
  if (startupTimeoutId) {
@@ -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
@@ -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
 
@@ -380,6 +339,12 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
380
339
  // completion gate can ask "did the last turn finish?" instead of comparing
381
340
  // counts that an echoed `turn.started` can skew.
382
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 || [],
383
348
  };
384
349
 
385
350
  nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
@@ -432,6 +397,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
432
397
 
433
398
  if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
434
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);
435
405
  } else if (!nextState.sessionId && typeof data.session_id === 'string') {
436
406
  nextState.sessionId = data.session_id;
437
407
  }
@@ -1030,7 +1000,7 @@ export const executeCodexCommand = async params => {
1030
1000
  if (chunk.type === 'stdout') {
1031
1001
  const raw = chunk.data.toString();
1032
1002
  if (argv.verbose) {
1033
- await log(raw);
1003
+ await log(raw, { stream: 'stdout' });
1034
1004
  }
1035
1005
  lastMessage = raw;
1036
1006
  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));
@@ -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}] [${level.toUpperCase()}]`;
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
- console.log(sanitizedMessage);
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 {
@@ -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
@@ -566,7 +566,7 @@ export const executeQwenCommand = async params => {
566
566
  for await (const chunk of execCommand.stream()) {
567
567
  if (chunk.type === 'stdout') {
568
568
  const output = chunk.data.toString();
569
- await log(output);
569
+ await log(output, { stream: 'stdout' });
570
570
  allOutput += output;
571
571
  qwenState = parseQwenStreamJsonOutput(output, qwenState);
572
572
  }