@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/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/src/bidirectional-interactive.lib.mjs +6 -2
- package/src/child-exit.lib.mjs +107 -0
- package/src/codex-health.lib.mjs +21 -1
- package/src/codex.lib.mjs +43 -5
- package/src/codex.run-diagnostics.lib.mjs +11 -0
- package/src/contributing-guidelines.lib.mjs +19 -6
- package/src/development-log.lib.mjs +82 -5
- package/src/fix.ci-cd-issue.lib.mjs +5 -3
- package/src/fix.mjs +5 -2
- package/src/github-entity-validation.lib.mjs +4 -1
- package/src/github.lib.mjs +3 -3
- package/src/hive.mjs +15 -21
- package/src/isolation-runner.lib.mjs +5 -2
- package/src/lib.mjs +21 -0
- package/src/log-growth.lib.mjs +94 -0
- package/src/opencode.lib.mjs +17 -3
- package/src/pull-request-changes.lib.mjs +94 -24
- package/src/qwen.lib.mjs +42 -8
- package/src/review.mjs +12 -3
- package/src/session-kill-recovery.lib.mjs +6 -2
- package/src/solve.auto-continue.lib.mjs +6 -2
- package/src/solve.auto-merge.lib.mjs +1 -1
- package/src/solve.keep-working.lib.mjs +7 -2
- package/src/solve.minimal-restart-prompt.lib.mjs +11 -3
- package/src/solve.preparation.lib.mjs +5 -1
- package/src/solve.progress-monitoring.lib.mjs +5 -1
- package/src/solve.repository.lib.mjs +5 -2
- package/src/solve.results.lib.mjs +13 -8
- package/src/task.mjs +5 -3
- package/src/telegram-command-execution.lib.mjs +5 -2
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
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
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
|
-
|
|
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() ||
|
|
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 };
|
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).
|
|
@@ -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
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
67
|
-
};
|
|
132
|
+
closeSection();
|
|
68
133
|
|
|
69
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
|
107
|
-
const
|
|
108
|
-
const realSections = sections.filter(section => !isPlaceholderSection(section));
|
|
176
|
+
const { filesChanged, additions, deletions, placeholderSections } = measureDiff(diffOutput);
|
|
177
|
+
const diffBytes = diffOutput.length;
|
|
109
178
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|