@link-assistant/hive-mind 2.11.8 → 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/src/hive.mjs CHANGED
@@ -35,6 +35,7 @@ if (earlyArgs.includes('--help') || earlyArgs.includes('-h')) {
35
35
  }
36
36
  }
37
37
  export { createYargsConfig } from './hive.config.lib.mjs';
38
+ import { attachChildExitHandlers } from './child-exit.lib.mjs';
38
39
  import { isDirectExecution, withTimeout } from './hive.bootstrap.lib.mjs';
39
40
  import { createShutdownManager } from './hive.shutdown.lib.mjs';
40
41
  const isRunningDirectly = isDirectExecution(process.argv[1], import.meta.url);
@@ -862,27 +863,20 @@ if (isRunningDirectly) {
862
863
  }
863
864
  });
864
865
 
865
- // Handle process completion
866
- child.on('close', code => {
867
- activeSolveChildren.delete(child); // Issue #1823: no longer in-flight
868
- exitCode = code || 0;
869
- resolve();
870
- });
871
-
872
- // Handle process errors
873
- child.on('error', error => {
874
- activeSolveChildren.delete(child); // Issue #1823: no longer in-flight
875
- exitCode = 1;
876
- log(` [${solveCommand} worker-${workerId} ERROR] Process error: ${error.message}`, {
877
- level: 'error',
878
- }).catch(logError => {
879
- reportError(logError, {
880
- context: 'worker_process_error_log',
881
- workerId,
882
- operation: 'log_process_error',
883
- });
884
- });
885
- resolve();
866
+ // Handle process completion and spawn failure. Issue #2135: a signalled
867
+ // child reports `code === null`, which `code || 0` read as success.
868
+ attachChildExitHandlers({
869
+ child,
870
+ command: solveCommand,
871
+ label: ` [${solveCommand} worker-${workerId}]`,
872
+ errorLabel: ` [${solveCommand} worker-${workerId} ERROR]`,
873
+ log,
874
+ onLogError: (logError, operation) => reportError(logError, { context: 'worker_child_exit_log', workerId, operation }),
875
+ onExit: result => {
876
+ activeSolveChildren.delete(child); // Issue #1823: no longer in-flight
877
+ exitCode = result.exitCode;
878
+ resolve();
879
+ },
886
880
  });
887
881
  });
888
882
 
@@ -15,6 +15,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
15
15
 
16
16
  import crypto from 'crypto';
17
17
  import { spawn } from 'node:child_process';
18
+ import { describeChildExit } from './child-exit.lib.mjs';
18
19
  import { lookup as lookupHost } from 'node:dns/promises';
19
20
  import fs from 'node:fs';
20
21
  import os from 'node:os';
@@ -351,7 +352,9 @@ async function runStartCommand(binPath, startCommandArgs) {
351
352
  error: error.message,
352
353
  });
353
354
  });
354
- child.on('close', code => {
355
+ // Issue #2135: keep `signal` - the captured session's child was killed by
356
+ // one, and `code` alone was null.
357
+ child.on('close', (code, signal) => {
355
358
  const output = (stdout + (stderr ? `\n${stderr}` : '')).trim();
356
359
  if (code === 0) {
357
360
  resolve({ success: true, output, error: null });
@@ -359,7 +362,7 @@ async function runStartCommand(binPath, startCommandArgs) {
359
362
  resolve({
360
363
  success: false,
361
364
  output,
362
- error: stderr.trim() || `start-command exited with code ${code}`,
365
+ error: stderr.trim() || describeChildExit({ command: 'start-command', code, signal }),
363
366
  });
364
367
  }
365
368
  });
package/src/lib.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { ensureUseM } from './use-m-bootstrap.lib.mjs';
3
3
  import { createCredentialStreamSanitizer, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
+ import { recordLogBytes, resetLogGrowth } from './log-growth.lib.mjs'; // issue #2135: notice a session log that is running away
4
5
 
5
6
  export { maskToken };
6
7
 
@@ -51,6 +52,23 @@ export let logFile = null;
51
52
  */
52
53
  export const setLogFile = path => {
53
54
  logFile = path;
55
+ resetLogGrowth(); // issue #2135: a new log starts its growth accounting over
56
+ };
57
+
58
+ /**
59
+ * Issue #2135: count what is written to the session log and say so once the
60
+ * total stops being reasonable. Called from every append path below.
61
+ *
62
+ * The warning is emitted with console.warn rather than log(): these call sites
63
+ * are inside the append paths themselves, and console output is captured into
64
+ * the same log by the stdio interceptor, so the evidence lands in the log that
65
+ * is misbehaving.
66
+ *
67
+ * @param {string} appendedText - exactly what was appended, including newline.
68
+ */
69
+ const noteLogBytesWritten = appendedText => {
70
+ const warning = recordLogBytes(Buffer.byteLength(appendedText));
71
+ if (warning) console.warn(warning);
54
72
  };
55
73
 
56
74
  /**
@@ -100,6 +118,7 @@ export const log = async (message, options = {}) => {
100
118
  try {
101
119
  await fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 });
102
120
  await fs.chmod(logFile, 0o600);
121
+ noteLogBytesWritten(logMessage + '\n');
103
122
  } catch (error) {
104
123
  // Silent fail for file append errors to avoid infinite loop
105
124
  // but report to Sentry in verbose mode
@@ -349,6 +368,7 @@ export const setupStdioLogInterceptor = () => {
349
368
  const logMessage = `[${new Date().toISOString()}] [STDOUT] ${text.replace(/\n$/, '')}`;
350
369
  fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 })
351
370
  .then(() => fs.chmod(logFile, 0o600))
371
+ .then(() => noteLogBytesWritten(logMessage + '\n')) // issue #2135
352
372
  .catch(() => {
353
373
  // Silent fail to avoid infinite loops
354
374
  });
@@ -382,6 +402,7 @@ export const setupStdioLogInterceptor = () => {
382
402
  const logMessage = `[${new Date().toISOString()}] [STDERR] ${text.replace(/\n$/, '')}`;
383
403
  fs.appendFile(logFile, logMessage + '\n', { mode: 0o600 })
384
404
  .then(() => fs.chmod(logFile, 0o600))
405
+ .then(() => noteLogBytesWritten(logMessage + '\n')) // issue #2135
385
406
  .catch(() => {
386
407
  // Silent fail to avoid infinite loops
387
408
  });
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Notice a session log that is running away (issue #2135).
5
+ *
6
+ * The log captured for that issue reached 286 MB / 1,354,845 lines because a
7
+ * single `gh pr diff` answer was mirrored to stdout, copied into the session
8
+ * log by the stdio interceptor, committed as the development log, and so
9
+ * included in the *next* run's diff - each round bigger than the last. Nothing
10
+ * said a word about it until the solve process died on the V8 heap limit.
11
+ *
12
+ * This module keeps a running count of the bytes written to the log and hands
13
+ * back a warning the first time the total crosses each threshold, so the log
14
+ * itself carries the evidence of its own growth.
15
+ *
16
+ * @module log-growth
17
+ */
18
+
19
+ const MEGABYTE = 1024 * 1024;
20
+
21
+ /**
22
+ * Sizes a session log has no business reaching.
23
+ *
24
+ * A busy solve session writes single-digit megabytes; the captured runaway
25
+ * session was two orders of magnitude past that. Warning three times (rather
26
+ * than once) keeps the trail readable: the distance between the warnings says
27
+ * how fast the log is growing.
28
+ */
29
+ export const LOG_GROWTH_THRESHOLDS = [64 * MEGABYTE, 256 * MEGABYTE, 1024 * MEGABYTE];
30
+
31
+ const formatBytes = bytes => {
32
+ if (bytes >= 1024 * MEGABYTE) return `${(bytes / (1024 * MEGABYTE)).toFixed(1)} GB`;
33
+ if (bytes >= MEGABYTE) return `${(bytes / MEGABYTE).toFixed(1)} MB`;
34
+ return `${(bytes / 1024).toFixed(1)} KB`;
35
+ };
36
+
37
+ /**
38
+ * Create an independent growth tracker.
39
+ *
40
+ * @param {object} [params]
41
+ * @param {number[]} [params.thresholds] - ascending byte counts to warn at.
42
+ * @returns {{record: (bytes: number) => string|null, reset: () => void, total: () => number}}
43
+ * `record` returns a warning message the first time the running total reaches
44
+ * the next threshold, and null otherwise.
45
+ */
46
+ export const createLogGrowthTracker = ({ thresholds = LOG_GROWTH_THRESHOLDS } = {}) => {
47
+ let total = 0;
48
+ let nextIndex = 0;
49
+
50
+ return {
51
+ record(bytes) {
52
+ if (!Number.isFinite(bytes) || bytes <= 0) return null;
53
+ total += bytes;
54
+ if (nextIndex >= thresholds.length || total < thresholds[nextIndex]) return null;
55
+
56
+ // Skip past every threshold this write blew through, so a single huge
57
+ // append produces one warning naming the size actually reached.
58
+ while (nextIndex < thresholds.length && total >= thresholds[nextIndex]) nextIndex += 1;
59
+
60
+ return `⚠️ Session log has grown to ${formatBytes(total)}. Something is writing very large output into it - mirrored command output (for example a "gh pr diff" of a branch that has logs committed to it) is the usual cause. See docs/case-studies/issue-2135.`;
61
+ },
62
+ reset() {
63
+ total = 0;
64
+ nextIndex = 0;
65
+ },
66
+ total() {
67
+ return total;
68
+ },
69
+ };
70
+ };
71
+
72
+ const defaultTracker = createLogGrowthTracker();
73
+
74
+ /**
75
+ * Count bytes written to the current session log.
76
+ *
77
+ * @param {number} bytes
78
+ * @returns {string|null} A warning to emit once, or null.
79
+ */
80
+ export const recordLogBytes = bytes => defaultTracker.record(bytes);
81
+
82
+ /**
83
+ * Start counting again - called when a new log file is set.
84
+ */
85
+ export const resetLogGrowth = () => defaultTracker.reset();
86
+
87
+ /**
88
+ * Bytes written to the current session log so far.
89
+ *
90
+ * @returns {number}
91
+ */
92
+ export const getLoggedBytes = () => defaultTracker.total();
93
+
94
+ export default { createLogGrowthTracker, recordLogBytes, resetLogGrowth, getLoggedBytes, LOG_GROWTH_THRESHOLDS };
@@ -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 {
@@ -36,6 +36,16 @@
36
36
  */
37
37
 
38
38
  import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
39
+ import { quietProbe } from './quiet-probe.lib.mjs';
40
+
41
+ /**
42
+ * Size at which a pull-request diff is worth complaining about (issue #2135).
43
+ *
44
+ * A diff this large is never the AI's source change: in the captured run it was
45
+ * CI logs and the solver's own development log committed into the branch. The
46
+ * warning is the early signal that was missing while the log grew to 286 MB.
47
+ */
48
+ const LARGE_DIFF_WARNING_BYTES = 8 * 1024 * 1024;
39
49
 
40
50
  /**
41
51
  * The solver's own scaffolding files, recognised by the content it writes into
@@ -52,24 +62,76 @@ const PLACEHOLDER_CONTENT_PATTERNS = new Map([
52
62
  ['CLAUDE.md', [/^\+Issue to solve: \S+/m, /^\+Your prepared branch: \S+/m]],
53
63
  ]);
54
64
 
55
- /** Split a unified diff into one section per file. */
56
- const splitDiffByFile = diff => {
57
- const sections = [];
58
- for (const line of diff.split('\n')) {
65
+ /**
66
+ * Measure a unified diff in a single pass.
67
+ *
68
+ * Issue #2135: the previous implementation split the whole diff into an array
69
+ * of lines, concatenated every line back into a per-file `body` string, and
70
+ * then counted additions with `body.match(/^\+[^+]/gm)` - a regex whose result
71
+ * is an array holding one string per added line. For the 60 MB pull-request
72
+ * diff captured in that run (the AI had committed CI logs and the solver's own
73
+ * development log into the branch) those three copies of the diff, plus a
74
+ * multi-million-entry match array, were a large part of the heap that ended the
75
+ * session with `FATAL ERROR: Reached heap limit`.
76
+ *
77
+ * This pass keeps no copy of the diff: it walks the string by line offsets,
78
+ * counts as it goes, and retains section text only for the two paths that can
79
+ * possibly be the solver's placeholder.
80
+ *
81
+ * The counting rules are unchanged: a line is an addition when it starts with
82
+ * `+` followed by a character other than `+` (so the `+++ b/path` header is not
83
+ * counted), and a deletion when it starts with `-` followed by a character
84
+ * other than `-`. Lines before the first `diff --git` header belong to no file
85
+ * and are ignored, exactly as they were when sections were built by splitting.
86
+ *
87
+ * @param {string} diff - unified diff text, possibly empty.
88
+ * @returns {{filesChanged: number, additions: number, deletions: number, placeholderSections: number}}
89
+ */
90
+ const measureDiff = diff => {
91
+ let filesChanged = 0;
92
+ let additions = 0;
93
+ let deletions = 0;
94
+ let placeholderSections = 0;
95
+ let section = null;
96
+
97
+ const closeSection = () => {
98
+ if (!section) return;
99
+ const isPlaceholder = Boolean(section.patterns) && section.patterns.every(pattern => pattern.test(section.body));
100
+ if (isPlaceholder) placeholderSections += 1;
101
+ else {
102
+ filesChanged += 1;
103
+ additions += section.additions;
104
+ deletions += section.deletions;
105
+ }
106
+ section = null;
107
+ };
108
+
109
+ for (let start = 0; start < diff.length; ) {
110
+ let end = diff.indexOf('\n', start);
111
+ if (end === -1) end = diff.length;
112
+ const line = diff.slice(start, end);
113
+ start = end + 1;
114
+
59
115
  if (line.startsWith('diff --git ')) {
116
+ closeSection();
60
117
  const match = /^diff --git a\/(.+) b\/(.+)$/.exec(line);
61
- sections.push({ path: match ? match[2] : '', body: '' });
118
+ const path = match ? match[2] : '';
119
+ const patterns = PLACEHOLDER_CONTENT_PATTERNS.get(path) || null;
120
+ section = { patterns, body: '', additions: 0, deletions: 0 };
62
121
  continue;
63
122
  }
64
- if (sections.length > 0) sections[sections.length - 1].body += `${line}\n`;
123
+ if (!section) continue;
124
+ // Only a placeholder candidate needs its text kept; every other file is
125
+ // reduced to two counters as it streams past.
126
+ if (section.patterns) section.body += `${line}\n`;
127
+ if (line.length > 1) {
128
+ if (line[0] === '+' && line[1] !== '+') section.additions += 1;
129
+ else if (line[0] === '-' && line[1] !== '-') section.deletions += 1;
130
+ }
65
131
  }
66
- return sections;
67
- };
132
+ closeSection();
68
133
 
69
- /** True when this file section is nothing but the solver's own placeholder. */
70
- const isPlaceholderSection = section => {
71
- const patterns = PLACEHOLDER_CONTENT_PATTERNS.get(section.path);
72
- return Boolean(patterns) && patterns.every(pattern => pattern.test(section.body));
134
+ return { filesChanged, additions, deletions, placeholderSections };
73
135
  };
74
136
 
75
137
  /**
@@ -84,17 +146,25 @@ const isPlaceholderSection = section => {
84
146
  * @param {string} params.repo
85
147
  * @param {number} params.prNumber
86
148
  * @param {Function} params.$ command-stream tagged-template executor
87
- * @returns {Promise<{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, placeholderOnly: boolean, measured: boolean}>}
149
+ * @param {Function} [params.log] - optional logger for the size diagnostic
150
+ * @returns {Promise<{hasChanges: boolean, filesChanged: number, additions: number, deletions: number, placeholderOnly: boolean, measured: boolean, diffBytes: number}>}
88
151
  * The counts cover the AI's own work: the solver's placeholder file is
89
152
  * excluded and reported through `placeholderOnly` instead. `measured` is
90
153
  * false when the diff could not be fetched, in which case callers must not
91
- * treat the pull request as empty.
154
+ * treat the pull request as empty. `diffBytes` is the size of the diff that
155
+ * was measured, so a caller can see a runaway pull request growing.
92
156
  */
93
- export const getPullRequestChangeStats = async ({ owner, repo, prNumber, $ }) => {
157
+ export const getPullRequestChangeStats = async ({ owner, repo, prNumber, $, log = null }) => {
94
158
  let diffOutput = '';
95
159
  let measured = false;
96
160
  try {
97
- const result = await ghWithRateLimitRetry(() => $`gh pr diff ${prNumber} --repo ${owner}/${repo}`, { label: `pr diff ${owner}/${repo}#${prNumber}` });
161
+ // Issue #2135: `mirror: false`. This diff is read to answer one yes/no
162
+ // question, and every caller reports the answer in words - but it was being
163
+ // echoed into the log that the solver then attaches to the pull request and
164
+ // (with --development-log) commits into the branch, which put the previous
165
+ // copy of the diff inside the next one. Seven such copies grew one session
166
+ // log to 286 MB and ended it with a V8 out-of-memory abort.
167
+ const result = await ghWithRateLimitRetry(() => quietProbe($)`gh pr diff ${prNumber} --repo ${owner}/${repo}`, { label: `pr diff ${owner}/${repo}#${prNumber}` });
98
168
  if (result.code === 0) {
99
169
  diffOutput = result.stdout.toString();
100
170
  measured = true;
@@ -103,22 +173,22 @@ export const getPullRequestChangeStats = async ({ owner, repo, prNumber, $ }) =>
103
173
  // Leave measured false: an unreachable API must not read as "no changes".
104
174
  }
105
175
 
106
- const sections = splitDiffByFile(diffOutput);
107
- const placeholderSections = sections.filter(isPlaceholderSection);
108
- const realSections = sections.filter(section => !isPlaceholderSection(section));
176
+ const { filesChanged, additions, deletions, placeholderSections } = measureDiff(diffOutput);
177
+ const diffBytes = diffOutput.length;
109
178
 
110
- const countMatches = (pattern, text) => (text.match(pattern) || []).length;
111
- const filesChanged = realSections.length;
112
- const additions = realSections.reduce((total, section) => total + countMatches(/^\+[^+]/gm, section.body), 0);
113
- const deletions = realSections.reduce((total, section) => total + countMatches(/^-[^-]/gm, section.body), 0);
179
+ if (measured && diffBytes >= LARGE_DIFF_WARNING_BYTES && typeof log === 'function') {
180
+ // Always megabytes: the threshold itself is 8 MB, so no unit choice is needed.
181
+ await log(`⚠️ Pull request #${prNumber} diff is ${(diffBytes / (1024 * 1024)).toFixed(1)} MB - measuring it is slow and memory-hungry; check whether logs or build output were committed to the branch`, { level: 'warning' });
182
+ }
114
183
 
115
184
  return {
116
185
  hasChanges: filesChanged > 0,
117
186
  filesChanged,
118
187
  additions,
119
188
  deletions,
120
- placeholderOnly: filesChanged === 0 && placeholderSections.length > 0,
189
+ placeholderOnly: filesChanged === 0 && placeholderSections > 0,
121
190
  measured,
191
+ diffBytes,
122
192
  };
123
193
  };
124
194
 
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
package/src/review.mjs CHANGED
@@ -48,6 +48,7 @@ const fs = (await use('fs')).promises;
48
48
 
49
49
  // Import shared functions from lib.mjs to follow DRY principle
50
50
  import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
51
+ import { QUIET_PROBE } from './quiet-probe.lib.mjs';
51
52
  import { reportError } from './sentry.lib.mjs';
52
53
  import * as memoryCheck from './memory-check.mjs';
53
54
 
@@ -222,7 +223,11 @@ let limitReached = false;
222
223
  try {
223
224
  // Get PR details first
224
225
  await log('📊 Getting pull request details...');
225
- const prDetailsResult = await $`gh pr view ${prUrl} --json title,body,headRefName,baseRefName,author,number,state,files`;
226
+ // Issue #2135: `mirror: false`. The answer is a JSON object holding the whole
227
+ // description and every changed file - it is written to a file for the AI
228
+ // tool and summarised in words below, so echoing it into the log only grew
229
+ // the log that is later attached to the pull request.
230
+ const prDetailsResult = await $(QUIET_PROBE)`gh pr view ${prUrl} --json title,body,headRefName,baseRefName,author,number,state,files`;
226
231
 
227
232
  if (prDetailsResult.code !== 0) {
228
233
  await log('Error: Failed to get PR details', { level: 'error' });
@@ -271,7 +276,11 @@ try {
271
276
 
272
277
  // Get the diff for the PR
273
278
  await log('📝 Getting PR diff...');
274
- const diffResult = await $`gh pr diff ${prUrl}`;
279
+ // Issue #2135: `mirror: false`. The diff is saved to a file (below) and its
280
+ // size is reported in words; mirroring it copied a whole pull-request diff
281
+ // into the session log, which is exactly the growth that ended one run with
282
+ // an out-of-memory abort.
283
+ const diffResult = await $(QUIET_PROBE)`gh pr diff ${prUrl}`;
275
284
 
276
285
  if (diffResult.code !== 0) {
277
286
  await log('Error: Failed to get PR diff', { level: 'error' });
@@ -426,7 +435,7 @@ Review this pull request thoroughly.`;
426
435
 
427
436
  try {
428
437
  // Get reviews for the PR
429
- const reviewsResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}/reviews --paginate --jq '.[] | select(.user.login == "'$(gh api user --jq .login)'") | {state, submitted_at}'`;
438
+ const reviewsResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/pulls/${prNumber}/reviews --paginate --jq '.[] | select(.user.login == "'$(gh api user --jq .login)'") | {state, submitted_at}'`;
430
439
 
431
440
  if (reviewsResult.code === 0 && reviewsResult.stdout.toString().trim()) {
432
441
  await log(`✅ Review has been submitted to PR #${prNumber}`);
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { spawn } from 'child_process';
26
+ import { describeChildExit } from './child-exit.lib.mjs';
26
27
  import { KILL_CAUSE_DISK_FULL, KILL_CAUSE_FORCED_KILL, KILL_CAUSE_OUT_OF_MEMORY } from './session-kill-diagnostics.lib.mjs';
27
28
  import { ON_SESSION_KILL_RESUME } from './session-kill-policy.lib.mjs';
28
29
 
@@ -137,7 +138,10 @@ export function spawnCapture(command, args, options = {}) {
137
138
  stderr += data.toString();
138
139
  });
139
140
  child.on('error', error => resolve({ code: 1, stdout, stderr: stderr || error.message }));
140
- child.on('close', code => resolve({ code, stdout, stderr }));
141
+ // Issue #2135: keep the signal. `close` reports `code === null` for a
142
+ // signalled child, and interpolating that null is how "exited with code
143
+ // null" reached a user notification with no cause attached.
144
+ child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr }));
141
145
  });
142
146
  }
143
147
 
@@ -181,7 +185,7 @@ export async function postKillRecoveryNotice({ pullRequestUrl, body, runCommand
181
185
  if (verbose) console.log(`[VERBOSE] Posted killed-session notice to ${pullRequestUrl}${url ? ` (${url})` : ''}`);
182
186
  return { posted: true, url, error: null };
183
187
  }
184
- const error = String(result?.stderr || result?.stdout || `gh pr comment exited with code ${result?.code}`).trim();
188
+ const error = String(result?.stderr || result?.stdout || describeChildExit({ command: 'gh pr comment', code: result?.code, signal: result?.signal })).trim();
185
189
  if (verbose) console.log(`[VERBOSE] Failed to post killed-session notice: ${error}`);
186
190
  return { posted: false, url: null, error };
187
191
  } catch (error) {
@@ -537,7 +537,10 @@ export const processAutoContinueForIssue = async (argv, isIssueUrl, urlNumber, o
537
537
 
538
538
  // List all branches in the fork that match the pattern issue-{issueNumber}-* (supports both 8-char and 12-char formats)
539
539
  const branchPattern = getIssueBranchPrefix(issueNumber);
540
- const branchListResult = await $`gh api --paginate repos/${forkRepo}/branches --jq '.[].name'`;
540
+ // Issue #2135: `mirror: false`. The list grows with the repository -
541
+ // a repository worked on by the solver accumulates one branch per
542
+ // issue - and only the matching ones are reported below.
543
+ const branchListResult = await $(QUIET_PROBE)`gh api --paginate repos/${forkRepo}/branches --jq '.[].name'`;
541
544
 
542
545
  if (branchListResult.code === 0) {
543
546
  const allBranches = branchListResult.stdout
@@ -575,7 +578,8 @@ export const processAutoContinueForIssue = async (argv, isIssueUrl, urlNumber, o
575
578
 
576
579
  // List all branches in the main repo that match the pattern issue-{issueNumber}-* (supports both 8-char and 12-char formats)
577
580
  const branchPattern = getIssueBranchPrefix(issueNumber);
578
- const branchListResult = await $`gh api --paginate repos/${owner}/${repo}/branches --jq '.[].name'`;
581
+ // Issue #2135: `mirror: false` - see the fork branch listing above.
582
+ const branchListResult = await $(QUIET_PROBE)`gh api --paginate repos/${owner}/${repo}/branches --jq '.[].name'`;
579
583
 
580
584
  if (branchListResult.code === 0) {
581
585
  const allBranches = branchListResult.stdout
@@ -293,7 +293,7 @@ export const watchUntilMergeable = async params => {
293
293
  // reproduction run posted "✅ Ready to merge - No pending changes" for a
294
294
  // pull request whose net diff was empty, so merging it would have closed
295
295
  // the issue without implementing anything.
296
- const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber, $ });
296
+ const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber, $, log });
297
297
  const isEmptyPullRequest = changeStats.measured && !changeStats.hasChanges;
298
298
  const emptyPullRequestBlocker = buildEmptyPullRequestBlocker(changeStats);
299
299
  if (isEmptyPullRequest) {