@link-assistant/hive-mind 2.11.8 → 2.11.9
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 +28 -0
- package/package.json +1 -1
- package/src/bidirectional-interactive.lib.mjs +6 -2
- package/src/child-exit.lib.mjs +107 -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/pull-request-changes.lib.mjs +94 -24
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 202bef0: Stop the session log from swallowing the pull request diff (issue #2135).
|
|
8
|
+
|
|
9
|
+
A session ended as `Work session failed (exit code: 1)` after its log reached
|
|
10
|
+
286 MB and the solve process died on the V8 heap limit.
|
|
11
|
+
`getPullRequestChangeStats` ran `gh pr diff` with command-stream's default
|
|
12
|
+
`mirror: true`, so the whole diff was echoed to stdout, copied into the session
|
|
13
|
+
log, committed to the branch by `--development-log`, and included again in the
|
|
14
|
+
next run's diff — each round larger than the last.
|
|
15
|
+
|
|
16
|
+
- `gh pr diff` is now read quietly and measured in a single streaming pass, with
|
|
17
|
+
a warning past 8 MB; the same quiet-probe treatment is applied to every other
|
|
18
|
+
unbounded-output probe, pinned by a source-scanning regression test.
|
|
19
|
+
- `src/contributing-guidelines.lib.mjs` no longer calls `.raw()` on a wrapped
|
|
20
|
+
`gh` promise, a `TypeError` that silently disabled guideline detection.
|
|
21
|
+
- New `src/child-exit.lib.mjs`: a child killed by a signal is reported as such
|
|
22
|
+
instead of `exited with code null`, and `hive` no longer records an
|
|
23
|
+
out-of-memory worker as a success.
|
|
24
|
+
- New `src/log-growth.lib.mjs`: the session log warns at 64 MB / 256 MB / 1 GB,
|
|
25
|
+
naming the usual cause, so a runaway log is visible before it is fatal.
|
|
26
|
+
- A failed development-log publication now discards the copies it wrote instead
|
|
27
|
+
of leaving them untracked, where they were read as the AI's uncommitted work
|
|
28
|
+
and triggered the restarts that multiplied the growth.
|
|
29
|
+
- `docs/case-studies/issue-2135` records the timeline, evidence and analysis.
|
|
30
|
+
|
|
3
31
|
## 2.11.8
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -243,7 +243,10 @@ export const createBidirectionalHandler = options => {
|
|
|
243
243
|
*/
|
|
244
244
|
const fetchCommentsFromEndpoint = async (apiPath, source) => {
|
|
245
245
|
try {
|
|
246
|
-
|
|
246
|
+
// Issue #2135: `mirror: false`. This is every comment body on the issue
|
|
247
|
+
// or pull request, re-read on a poll interval; mirroring it copied the
|
|
248
|
+
// whole conversation into the log once per poll.
|
|
249
|
+
const result = await quietProbe($)`gh api ${apiPath} --paginate --slurp`;
|
|
247
250
|
const parsed = JSON.parse(result.stdout?.toString() || '[]');
|
|
248
251
|
const comments = Array.isArray(parsed) && parsed.every(Array.isArray) ? parsed.flat() : parsed;
|
|
249
252
|
return comments.map(comment => ({
|
|
@@ -476,7 +479,8 @@ export const createBidirectionalHandler = options => {
|
|
|
476
479
|
if (!number || !owner || !repo) return null;
|
|
477
480
|
try {
|
|
478
481
|
const endpoint = kind === 'pr' ? `repos/${owner}/${repo}/pulls/${number}` : `repos/${owner}/${repo}/issues/${number}`;
|
|
479
|
-
|
|
482
|
+
// Issue #2135: `mirror: false` - the snapshot is compared, not shown.
|
|
483
|
+
const result = await quietProbe($)`gh api ${endpoint} --jq '{title, body}'`;
|
|
480
484
|
if (!result || result.code !== 0) return null;
|
|
481
485
|
const parsed = JSON.parse(result.stdout.toString() || '{}');
|
|
482
486
|
return { title: parsed.title || '', body: parsed.body || '' };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Say why a child process ended (issue #2135).
|
|
5
|
+
*
|
|
6
|
+
* The session captured for that issue ended with the solve child aborting on a
|
|
7
|
+
* V8 heap limit:
|
|
8
|
+
*
|
|
9
|
+
* FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
|
|
10
|
+
*
|
|
11
|
+
* `node` answers that by calling `abort()`, so the child is terminated by a
|
|
12
|
+
* signal and `child.on('close')` reports `code === null`. Every spawner in this
|
|
13
|
+
* repository interpolated that `code` straight into its message and produced
|
|
14
|
+
*
|
|
15
|
+
* ❌ Error: solve exited with code null
|
|
16
|
+
*
|
|
17
|
+
* which says nothing about memory, and the wrapper's own exit code 1 was all
|
|
18
|
+
* that reached the Telegram notification ("Work session failed (exit code:
|
|
19
|
+
* 1)"). The signal was known at that moment and simply thrown away.
|
|
20
|
+
*
|
|
21
|
+
* @module child-exit
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a signal says about how the child died.
|
|
26
|
+
*
|
|
27
|
+
* SIGABRT is the one that matters here: `node` aborts on a fatal V8 error, and
|
|
28
|
+
* "reached heap limit" is by far the most common of those in this codebase.
|
|
29
|
+
* SIGKILL is the other memory ending - the kernel's OOM killer, or a container
|
|
30
|
+
* runtime enforcing a memory limit, neither of which lets the process print
|
|
31
|
+
* anything at all.
|
|
32
|
+
*/
|
|
33
|
+
const SIGNAL_EXPLANATIONS = new Map([
|
|
34
|
+
['SIGABRT', 'the process aborted - for Node.js this is usually a fatal V8 error such as "Reached heap limit Allocation failed - JavaScript heap out of memory"; look for "FATAL ERROR" above'],
|
|
35
|
+
['SIGKILL', 'the process was killed outright - usually the kernel out-of-memory killer or a container memory limit; nothing it could print survives this'],
|
|
36
|
+
['SIGTERM', 'the process was asked to terminate - usually a timeout, a container stop, or another process shutting it down'],
|
|
37
|
+
['SIGINT', 'the process was interrupted (Ctrl+C)'],
|
|
38
|
+
['SIGSEGV', 'the process crashed with a segmentation fault'],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Describe how a spawned child ended, in a sentence a log reader can act on.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} params
|
|
45
|
+
* @param {string} params.command - what was spawned, as the reader knows it (e.g. `solve`).
|
|
46
|
+
* @param {number|null} [params.code] - the exit code from `close`/`exit`, null when signalled.
|
|
47
|
+
* @param {string|null} [params.signal] - the signal name from `close`/`exit`, when there is one.
|
|
48
|
+
* @returns {string} A description ending without punctuation, ready to be used
|
|
49
|
+
* as an `Error` message or logged as-is.
|
|
50
|
+
*/
|
|
51
|
+
export const describeChildExit = ({ command, code = null, signal = null }) => {
|
|
52
|
+
if (signal) {
|
|
53
|
+
const explanation = SIGNAL_EXPLANATIONS.get(signal);
|
|
54
|
+
return explanation ? `${command} was terminated by signal ${signal}: ${explanation}` : `${command} was terminated by signal ${signal}`;
|
|
55
|
+
}
|
|
56
|
+
if (code === null || code === undefined) {
|
|
57
|
+
// No code and no signal: rare, but "code null" on its own is exactly the
|
|
58
|
+
// uninformative message this module exists to replace.
|
|
59
|
+
return `${command} exited without a status code, so it did not finish normally (no signal was reported either)`;
|
|
60
|
+
}
|
|
61
|
+
return `${command} exited with code ${code}`;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* True when the ending looks like the child ran out of memory.
|
|
66
|
+
*
|
|
67
|
+
* Callers use this to add the one hint that would have saved the captured
|
|
68
|
+
* session: the child died of memory pressure, not of a failed check.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} params
|
|
71
|
+
* @param {number|null} [params.code]
|
|
72
|
+
* @param {string|null} [params.signal]
|
|
73
|
+
* @returns {boolean}
|
|
74
|
+
*/
|
|
75
|
+
export const isLikelyOutOfMemoryExit = ({ code = null, signal = null }) => signal === 'SIGABRT' || signal === 'SIGKILL' || (signal === null && code === 134);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wire `close`/`error` handlers that never lose a signal.
|
|
79
|
+
*
|
|
80
|
+
* The exit code handed to `onExit` is 1 for a signalled child: `code || 0`
|
|
81
|
+
* turned the `null` of a signalled exit into a success, so a worker killed by
|
|
82
|
+
* the OOM killer looked like a worker that finished its issue.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} params
|
|
85
|
+
* @param {import('node:child_process').ChildProcess} params.child
|
|
86
|
+
* @param {string} params.command - what was spawned, as the log reader knows it.
|
|
87
|
+
* @param {string} params.label - prefix for the exit line (e.g. ` [solve worker-1]`).
|
|
88
|
+
* @param {string} [params.errorLabel] - prefix for the spawn-failure line; defaults to `label`.
|
|
89
|
+
* @param {Function} params.log - async logger, `(message, options) => Promise`.
|
|
90
|
+
* @param {Function} [params.onLogError] - called as `(error, operation)` when logging itself fails.
|
|
91
|
+
* @param {Function} params.onExit - called once with `{ exitCode, code, signal, error }`.
|
|
92
|
+
*/
|
|
93
|
+
export const attachChildExitHandlers = ({ child, command, label, errorLabel = label, log, onLogError = () => {}, onExit }) => {
|
|
94
|
+
child.on('close', (code, signal) => {
|
|
95
|
+
if (signal) {
|
|
96
|
+
log(`${label} ${describeChildExit({ command, code, signal })}`, { level: 'error' }).catch(logError => onLogError(logError, 'log_child_signal_exit'));
|
|
97
|
+
}
|
|
98
|
+
onExit({ exitCode: signal ? 1 : code || 0, code, signal, error: null });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
child.on('error', error => {
|
|
102
|
+
log(`${errorLabel} Process error: ${error.message}`, { level: 'error' }).catch(logError => onLogError(logError, 'log_child_process_error'));
|
|
103
|
+
onExit({ exitCode: 1, code: null, signal: null, error });
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export default { describeChildExit, isLikelyOutOfMemoryExit, attachChildExitHandlers };
|
|
@@ -13,6 +13,7 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
13
13
|
const { $: __rawDollar$ } = await use('command-stream');
|
|
14
14
|
const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
|
|
15
15
|
const $ = wrapDollarWithGhRetry(__rawDollar$);
|
|
16
|
+
const { QUIET_PROBE } = await import('./quiet-probe.lib.mjs'); // issue #2135: keep large read-only probe payloads out of the attached log
|
|
16
17
|
/**
|
|
17
18
|
* Common paths where contributing guidelines might be found
|
|
18
19
|
*/
|
|
@@ -41,15 +42,25 @@ export async function detectContributingGuidelines(owner, repo) {
|
|
|
41
42
|
// Try to find CONTRIBUTING file in the repo
|
|
42
43
|
for (const path of CONTRIBUTING_PATHS) {
|
|
43
44
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// Issue #2135: `mirror: false` - the answer is the file's whole content,
|
|
46
|
+
// base64-encoded, and it is decoded into `result.content` below rather
|
|
47
|
+
// than read from the log.
|
|
48
|
+
//
|
|
49
|
+
// `.raw()` used to be called on this result: `$` is wrapped by
|
|
50
|
+
// `wrapDollarWithGhRetry`, which returns a plain promise for `gh`
|
|
51
|
+
// commands, so `.raw()` threw a TypeError that the `catch` below
|
|
52
|
+
// swallowed - every repository looked as if it had no contributing
|
|
53
|
+
// guidelines. The command-stream result fields are read directly instead.
|
|
54
|
+
const checkResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/contents/${path} 2>/dev/null`;
|
|
55
|
+
const checkText = checkResult.stdout ? checkResult.stdout.toString().trim() : '';
|
|
56
|
+
if (checkResult.code === 0 && checkText) {
|
|
46
57
|
result.found = true;
|
|
47
58
|
result.path = path;
|
|
48
59
|
result.url = `https://github.com/${owner}/${repo}/blob/main/${path}`;
|
|
49
60
|
|
|
50
61
|
// Try to get the content from the response
|
|
51
62
|
try {
|
|
52
|
-
const data = JSON.parse(
|
|
63
|
+
const data = JSON.parse(checkText);
|
|
53
64
|
if (data.content) {
|
|
54
65
|
// Decode base64 content
|
|
55
66
|
result.content = Buffer.from(data.content, 'base64').toString('utf-8');
|
|
@@ -68,9 +79,11 @@ export async function detectContributingGuidelines(owner, repo) {
|
|
|
68
79
|
// Try to find docs URL in README
|
|
69
80
|
if (!result.found) {
|
|
70
81
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
// Issue #2135: `mirror: false`, and the same `.raw()` fix as above.
|
|
83
|
+
const readme = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/readme 2>/dev/null`;
|
|
84
|
+
const readmeText = readme.stdout ? readme.stdout.toString().trim() : '';
|
|
85
|
+
if (readme.code === 0 && readmeText) {
|
|
86
|
+
const readmeData = JSON.parse(readmeText);
|
|
74
87
|
const readmeContent = Buffer.from(readmeData.content, 'base64').toString('utf-8');
|
|
75
88
|
|
|
76
89
|
// Look for contributing documentation URL
|
|
@@ -290,6 +290,62 @@ const verifyDevelopmentLogDirectory = async directoryPath => {
|
|
|
290
290
|
|
|
291
291
|
const getCommandOutput = result => (result?.stderr?.toString?.() || result?.stdout?.toString?.() || '').trim();
|
|
292
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Issue #2135: leave no untracked residue behind when publication fails.
|
|
295
|
+
*
|
|
296
|
+
* The artifacts are written into the user's workspace *before* they can be
|
|
297
|
+
* verified, staged and committed. When any of those steps fails the copies stay
|
|
298
|
+
* on disk, and every later `git status --porcelain` reports them:
|
|
299
|
+
*
|
|
300
|
+
* ⚠️ Development log collection failed: Development-log publication rescan
|
|
301
|
+
* found residual credential material.
|
|
302
|
+
* ?? dev/log/issues/191/pulls/192/sessions/
|
|
303
|
+
* 📝 Found uncommitted changes
|
|
304
|
+
* 🔄 AUTO-RESTART: Restarting Claude to handle uncommitted changes...
|
|
305
|
+
*
|
|
306
|
+
* The restarted session is then told it MUST commit those changes - so hive-mind
|
|
307
|
+
* asks the AI to commit hive-mind's own session log into the user's branch, and
|
|
308
|
+
* the next `gh pr diff` carries it (docs/case-studies/issue-2135, RC6).
|
|
309
|
+
*
|
|
310
|
+
* The copies are exactly that - copies; the originals stay in the session log
|
|
311
|
+
* and the tool's own state directory, so discarding them loses nothing. Only
|
|
312
|
+
* this run's session directory is removed, and only while it is still
|
|
313
|
+
* uncommitted.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} params
|
|
316
|
+
* @param {string} params.repositoryPath
|
|
317
|
+
* @param {string} params.sessionRelativeDirectory - this run's session directory
|
|
318
|
+
* @param {string} params.relativeDirectory - the development-log directory that may hold staged paths
|
|
319
|
+
* @param {Function} [params.$]
|
|
320
|
+
* @param {Function} [params.log]
|
|
321
|
+
* @returns {Promise<{discarded: boolean, reason?: string}>}
|
|
322
|
+
*/
|
|
323
|
+
export const discardUnpublishedDevelopmentLog = async ({ repositoryPath, sessionRelativeDirectory, relativeDirectory, $, log }) => {
|
|
324
|
+
if (!repositoryPath || !sessionRelativeDirectory) {
|
|
325
|
+
return { discarded: false, reason: 'nothing-to-discard' };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if ($ && relativeDirectory) {
|
|
329
|
+
// Unstage first: `git add -f` may already have run, and a staged-but-uncommitted
|
|
330
|
+
// path is just as good at triggering the restart loop as an untracked one.
|
|
331
|
+
try {
|
|
332
|
+
await $({ cwd: repositoryPath })`git reset -q -- ${relativeDirectory}`;
|
|
333
|
+
} catch (error) {
|
|
334
|
+
await log?.(`⚠️ Could not unstage development log artifacts: ${error.message}`, { level: 'warning' });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
await fs.rm(path.join(repositoryPath, sessionRelativeDirectory), { recursive: true, force: true });
|
|
340
|
+
} catch (error) {
|
|
341
|
+
await log?.(`⚠️ Could not remove unpublished development log artifacts in ${sessionRelativeDirectory}: ${error.message}`, { level: 'warning' });
|
|
342
|
+
return { discarded: false, reason: 'unremovable' };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await log?.(`🧹 Discarded unpublished development log artifacts in ${sessionRelativeDirectory} so they cannot be mistaken for the AI's uncommitted work (issue #2135)`);
|
|
346
|
+
return { discarded: true };
|
|
347
|
+
};
|
|
348
|
+
|
|
293
349
|
export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, $, log }) => {
|
|
294
350
|
if (!enabled) {
|
|
295
351
|
return { skipped: 'disabled' };
|
|
@@ -303,8 +359,24 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
303
359
|
// Issue #2048: verbose trace so the commit timing (relative to PR readiness signals) is diagnosable from logs.
|
|
304
360
|
await log?.(`🔍 Development log finalize: issue #${issueNumber ?? '?'}, PR #${prNumber ?? 'pending'}, branch ${branchName ?? 'none'}, session ${sessionId ?? 'none'}, log slice from byte ${logStartByte}`, { verbose: true });
|
|
305
361
|
|
|
362
|
+
// Kept outside the try so the failure paths below (and the catch) can clean up
|
|
363
|
+
// the copies this run wrote into the workspace - see issue #2135, RC6.
|
|
364
|
+
let artifacts = null;
|
|
365
|
+
// Unstaging is only meaningful once staging has been attempted, and issue
|
|
366
|
+
// #2111 requires that no git command run before the residual-credential
|
|
367
|
+
// rescan - so the cleanup gets `$` only after `git add` was reached.
|
|
368
|
+
let stagingAttempted = false;
|
|
369
|
+
const discardArtifacts = async () =>
|
|
370
|
+
discardUnpublishedDevelopmentLog({
|
|
371
|
+
repositoryPath,
|
|
372
|
+
sessionRelativeDirectory: artifacts?.sessionRelativeDirectory,
|
|
373
|
+
relativeDirectory: artifacts?.relativeDirectory,
|
|
374
|
+
$: stagingAttempted ? $ : null,
|
|
375
|
+
log,
|
|
376
|
+
});
|
|
377
|
+
|
|
306
378
|
try {
|
|
307
|
-
|
|
379
|
+
artifacts = await writeDevelopmentLogArtifacts({
|
|
308
380
|
repositoryPath,
|
|
309
381
|
logFile,
|
|
310
382
|
issueNumber,
|
|
@@ -328,10 +400,12 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
328
400
|
// call the publication helper.
|
|
329
401
|
await verifyDevelopmentLogDirectory(path.join(repositoryPath, artifacts.relativeDirectory));
|
|
330
402
|
|
|
403
|
+
stagingAttempted = true;
|
|
331
404
|
const addResult = await $({ cwd: repositoryPath })`git add -f -- ${artifacts.relativeDirectory}`;
|
|
332
405
|
if (addResult.code !== 0) {
|
|
333
406
|
await log?.(`⚠️ Could not stage development log: ${getCommandOutput(addResult)}`, { level: 'warning' });
|
|
334
|
-
|
|
407
|
+
await discardArtifacts();
|
|
408
|
+
return { ...artifacts, committed: false, pushed: false, discarded: true };
|
|
335
409
|
}
|
|
336
410
|
|
|
337
411
|
const diffResult = await $({ cwd: repositoryPath })`git diff --cached --quiet -- ${artifacts.relativeDirectory}`;
|
|
@@ -341,14 +415,16 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
341
415
|
}
|
|
342
416
|
if (diffResult.code !== 1) {
|
|
343
417
|
await log?.(`⚠️ Could not inspect staged development log changes: ${getCommandOutput(diffResult)}`, { level: 'warning' });
|
|
344
|
-
|
|
418
|
+
await discardArtifacts();
|
|
419
|
+
return { ...artifacts, committed: false, pushed: false, discarded: true };
|
|
345
420
|
}
|
|
346
421
|
|
|
347
422
|
const commitMessage = prNumber ? `Add development log for issue #${issueNumber} PR #${prNumber}` : `Add development log for issue #${issueNumber}`;
|
|
348
423
|
const commitResult = await $({ cwd: repositoryPath })`git commit -m ${commitMessage} -- ${artifacts.relativeDirectory}`;
|
|
349
424
|
if (commitResult.code !== 0) {
|
|
350
425
|
await log?.(`⚠️ Could not commit development log: ${getCommandOutput(commitResult)}`, { level: 'warning' });
|
|
351
|
-
|
|
426
|
+
await discardArtifacts();
|
|
427
|
+
return { ...artifacts, committed: false, pushed: false, discarded: true };
|
|
352
428
|
}
|
|
353
429
|
|
|
354
430
|
await log?.('✅ Development log committed');
|
|
@@ -368,6 +444,7 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
368
444
|
return { ...artifacts, committed: true, pushed: true };
|
|
369
445
|
} catch (error) {
|
|
370
446
|
await log?.(`⚠️ Development log collection failed: ${error.message}`, { level: 'warning' });
|
|
371
|
-
|
|
447
|
+
const cleanup = await discardArtifacts();
|
|
448
|
+
return { skipped: 'error', error, discarded: cleanup.discarded };
|
|
372
449
|
}
|
|
373
450
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { spawn } from 'child_process';
|
|
7
7
|
import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle, dedupeRunsByWorkflow } from './fix.ci-cd.lib.mjs';
|
|
8
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
8
9
|
import { createTaskIssue } from './task.issue-creation.lib.mjs';
|
|
9
10
|
|
|
10
11
|
function runCommand(command, args, options = {}) {
|
|
@@ -25,8 +26,8 @@ function runCommand(command, args, options = {}) {
|
|
|
25
26
|
child.on('error', error => {
|
|
26
27
|
resolve({ code: 1, stdout, stderr: stderr || error.message });
|
|
27
28
|
});
|
|
28
|
-
child.on('close', code => {
|
|
29
|
-
resolve({ code, stdout, stderr });
|
|
29
|
+
child.on('close', (code, signal) => {
|
|
30
|
+
resolve({ code, stdout, stderr, signal });
|
|
30
31
|
});
|
|
31
32
|
});
|
|
32
33
|
}
|
|
@@ -35,7 +36,8 @@ async function commandOutput(run, command, args) {
|
|
|
35
36
|
const result = await run(command, args);
|
|
36
37
|
if (result.code !== 0) {
|
|
37
38
|
const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
|
|
38
|
-
|
|
39
|
+
// Issue #2135: `describeChildExit` names a signal instead of "code null".
|
|
40
|
+
throw new Error(output || describeChildExit({ command, code: result.code, signal: result.signal }));
|
|
39
41
|
}
|
|
40
42
|
return result.stdout.trim();
|
|
41
43
|
}
|
package/src/fix.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import path from 'path';
|
|
|
17
17
|
import { spawn } from 'child_process';
|
|
18
18
|
import { fileURLToPath } from 'url';
|
|
19
19
|
import { buildSolveArgs, partitionFixArgs, summarizeRunFailures } from './fix.ci-cd.lib.mjs';
|
|
20
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
20
21
|
import { createCiCdIssue, prepareCiCdIssue } from './fix.ci-cd-issue.lib.mjs';
|
|
21
22
|
import { setupStdioLogInterceptor } from './lib.mjs';
|
|
22
23
|
|
|
@@ -122,9 +123,11 @@ async function main() {
|
|
|
122
123
|
env: process.env,
|
|
123
124
|
});
|
|
124
125
|
child.on('error', reject);
|
|
125
|
-
|
|
126
|
+
// Issue #2135: `signal` used to be dropped here, so a solve child that
|
|
127
|
+
// aborted on a V8 heap limit was reported as "solve exited with code null".
|
|
128
|
+
child.on('close', (code, signal) => {
|
|
126
129
|
if (code === 0) resolve();
|
|
127
|
-
else reject(new Error(
|
|
130
|
+
else reject(new Error(describeChildExit({ command: 'solve', code, signal })));
|
|
128
131
|
});
|
|
129
132
|
});
|
|
130
133
|
}
|
|
@@ -8,6 +8,7 @@ if (typeof globalThis.use === 'undefined') await ensureUseM();
|
|
|
8
8
|
const { $ } = await use('command-stream');
|
|
9
9
|
import { ghCmdRetry } from './lib.mjs';
|
|
10
10
|
import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
11
|
+
import { QUIET_PROBE } from './quiet-probe.lib.mjs';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Compute the Levenshtein edit distance between two strings.
|
|
@@ -116,7 +117,9 @@ export async function checkBaseBranchExists({ owner, repo, baseBranch, verbose =
|
|
|
116
117
|
export async function buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose = false }) {
|
|
117
118
|
let suggestion = '';
|
|
118
119
|
try {
|
|
119
|
-
|
|
120
|
+
// Issue #2135: `mirror: false`. Only the closest name is reported (below),
|
|
121
|
+
// yet the raw answer is every branch in the repository.
|
|
122
|
+
const listResult = await ghCmdRetry(() => $(QUIET_PROBE)`gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`, { label: `list branches ${owner}/${repo}` });
|
|
120
123
|
if (listResult.code === 0) {
|
|
121
124
|
const branches = listResult.stdout
|
|
122
125
|
.toString()
|
package/src/github.lib.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { buildCostInfoString } from './github-cost-info.lib.mjs';
|
|
|
19
19
|
export { buildCostInfoString };
|
|
20
20
|
// #1756: route gh exec calls through transient + rate-limit retry wrapper
|
|
21
21
|
import { execGhWithRetry } from './github-rate-limit.lib.mjs';
|
|
22
|
-
import { QUIET_PROBE } from './quiet-probe.lib.mjs'; //
|
|
22
|
+
import { QUIET_PROBE } from './quiet-probe.lib.mjs'; // issues #2130, #2135: keep read-only probe payloads out of the attached log
|
|
23
23
|
// Issue #1625: Named marker constants (single source of truth) + in-memory
|
|
24
24
|
// tracking for tool-posted comments. See tool-comments.lib.mjs for design.
|
|
25
25
|
import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
|
|
@@ -1324,7 +1324,7 @@ export function isGitHubUrlType(url, types) {
|
|
|
1324
1324
|
*/
|
|
1325
1325
|
export async function ghPrView({ prNumber, owner, repo, jsonFields = 'headRefName,body,number,mergeStateStatus,state,headRepositoryOwner' }) {
|
|
1326
1326
|
try {
|
|
1327
|
-
const prResult = await
|
|
1327
|
+
const prResult = await $(QUIET_PROBE)`gh pr view ${prNumber} --repo ${owner}/${repo} --json ${jsonFields}`;
|
|
1328
1328
|
const stdout = prResult.stdout.toString();
|
|
1329
1329
|
const stderr = prResult.stderr ? prResult.stderr.toString() : '';
|
|
1330
1330
|
const code = prResult.code || 0;
|
|
@@ -1364,7 +1364,7 @@ export async function ghPrView({ prNumber, owner, repo, jsonFields = 'headRefNam
|
|
|
1364
1364
|
*/
|
|
1365
1365
|
export async function ghIssueView({ issueNumber, owner, repo, jsonFields = 'number,title' }) {
|
|
1366
1366
|
try {
|
|
1367
|
-
const issueResult = await
|
|
1367
|
+
const issueResult = await $(QUIET_PROBE)`gh issue view ${issueNumber} --repo ${owner}/${repo} --json ${jsonFields}`;
|
|
1368
1368
|
const stdout = issueResult.stdout.toString();
|
|
1369
1369
|
const stderr = issueResult.stderr ? issueResult.stderr.toString() : '';
|
|
1370
1370
|
const code = issueResult.code || 0;
|
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 };
|
|
@@ -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/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) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { QUIET_PROBE } from './quiet-probe.lib.mjs';
|
|
2
3
|
import { ensureUseM } from './use-m-bootstrap.lib.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -68,7 +69,8 @@ export const collectDeferredWorkSources = async ({ owner, repo, prNumber, result
|
|
|
68
69
|
|
|
69
70
|
// 1. Pull request description
|
|
70
71
|
try {
|
|
71
|
-
|
|
72
|
+
// Issue #2135: `mirror: false` - the description is scanned here, not shown.
|
|
73
|
+
const prResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/pulls/${prNumber} --jq '.body // ""'`;
|
|
72
74
|
if (prResult.code === 0) {
|
|
73
75
|
const body = prResult.stdout.toString();
|
|
74
76
|
if (body && body.trim()) {
|
|
@@ -86,7 +88,10 @@ export const collectDeferredWorkSources = async ({ owner, repo, prNumber, result
|
|
|
86
88
|
|
|
87
89
|
// 3. Changed markdown documents (scan only added lines from the diff)
|
|
88
90
|
try {
|
|
89
|
-
|
|
91
|
+
// Issue #2135: `mirror: false`. Every entry carries the file's patch, so
|
|
92
|
+
// this answer is as large as the pull request's diff - and it was being
|
|
93
|
+
// echoed into the log that gets attached to that same pull request.
|
|
94
|
+
const filesResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/pulls/${prNumber}/files --paginate`;
|
|
90
95
|
if (filesResult.code === 0) {
|
|
91
96
|
const files = JSON.parse(filesResult.stdout.toString() || '[]');
|
|
92
97
|
for (const file of files) {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* @see case-studies/issue-661-session-resume-cost-optimization/
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { QUIET_PROBE } from './quiet-probe.lib.mjs';
|
|
13
|
+
|
|
12
14
|
// Note: This module does not import $ directly
|
|
13
15
|
// Functions receive $ as a parameter from the calling module
|
|
14
16
|
// This ensures consistent command executor usage across the codebase
|
|
@@ -28,9 +30,11 @@ export const generateMinimalRestartPrompt = async (tempDir, $) => {
|
|
|
28
30
|
const uncommittedFiles = gitStatus.stdout.toString().trim();
|
|
29
31
|
|
|
30
32
|
// Get brief diff summaries (not full diffs to keep the prompt minimal)
|
|
31
|
-
|
|
33
|
+
// Issue #2135: `mirror: false` - the summaries go into the prompt below, so
|
|
34
|
+
// echoing them into the log only duplicates them into the attached log file.
|
|
35
|
+
const gitDiffStat = await $({ cwd: tempDir, ...QUIET_PROBE })`git diff --stat`;
|
|
32
36
|
const unstagedDiffSummary = gitDiffStat.stdout.toString().trim();
|
|
33
|
-
const gitCachedDiffStat = await $({ cwd: tempDir })`git diff --cached --stat`;
|
|
37
|
+
const gitCachedDiffStat = await $({ cwd: tempDir, ...QUIET_PROBE })`git diff --cached --stat`;
|
|
34
38
|
const stagedDiffSummary = gitCachedDiffStat.stdout.toString().trim();
|
|
35
39
|
const summarySections = [];
|
|
36
40
|
if (unstagedDiffSummary) summarySections.push(`Unstaged changes:\n${unstagedDiffSummary}`);
|
|
@@ -69,7 +73,11 @@ export const generateFullRestartPrompt = async (issueUrl, issueBody, prNumber, f
|
|
|
69
73
|
const gitStatus = await $({ cwd: tempDir })`git status --porcelain`;
|
|
70
74
|
const uncommittedFiles = gitStatus.stdout.toString().trim();
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
// Issue #2135: `mirror: false`. This is the working tree's whole diff and it
|
|
77
|
+
// is embedded in the prompt below; mirroring it wrote a second copy into the
|
|
78
|
+
// session log, which is attached to the pull request and (with
|
|
79
|
+
// --development-log) committed into the branch the diff is taken from.
|
|
80
|
+
const gitDiff = await $({ cwd: tempDir, ...QUIET_PROBE })`git diff`;
|
|
73
81
|
const fullDiff = gitDiff.stdout.toString();
|
|
74
82
|
|
|
75
83
|
let prompt = `
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
7
|
+
import { quietProbe } from './quiet-probe.lib.mjs';
|
|
7
8
|
// Import feedback detection functionality
|
|
8
9
|
const feedback = await import('./solve.feedback.lib.mjs');
|
|
9
10
|
const { detectAndCountFeedback } = feedback;
|
|
@@ -45,7 +46,10 @@ export async function prepareFeedbackAndTimestamps({ tempDir = null, prNumber, b
|
|
|
45
46
|
|
|
46
47
|
// Get the last comment's timestamp (if any)
|
|
47
48
|
// Use --paginate to get all comments - GitHub API returns max 30 per page by default
|
|
48
|
-
|
|
49
|
+
// Issue #2135: `mirror: false`. Only the last comment's timestamp is read
|
|
50
|
+
// from this answer, but the answer itself is every comment body on the
|
|
51
|
+
// issue - tens of kilobytes echoed into the log on every run.
|
|
52
|
+
const commentsResult = await quietProbe($)`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
|
|
49
53
|
|
|
50
54
|
if (commentsResult.code !== 0) {
|
|
51
55
|
await log(`Warning: Failed to get comments: ${commentsResult.stderr ? commentsResult.stderr.toString() : 'Unknown error'}`, { level: 'warning' });
|
|
@@ -30,6 +30,7 @@ import { LIVE_PROGRESS_SECTION_START_MARKER, LIVE_PROGRESS_SECTION_END_MARKER, p
|
|
|
30
30
|
import { writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
|
|
31
31
|
|
|
32
32
|
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
33
|
+
import { quietProbe } from './quiet-probe.lib.mjs'; // issue #2135: keep large read-only probe payloads out of the attached log
|
|
33
34
|
/**
|
|
34
35
|
* Configuration constants for progress monitoring
|
|
35
36
|
*/
|
|
@@ -280,7 +281,10 @@ export const createProgressMonitor = ({ owner, repo, prNumber, $, log, verbose =
|
|
|
280
281
|
state.currentTodos = todos;
|
|
281
282
|
|
|
282
283
|
// Fetch current PR description
|
|
283
|
-
|
|
284
|
+
// Issue #2135: `mirror: false`. This runs on every progress update and
|
|
285
|
+
// the answer is the whole pull-request description, which by then holds
|
|
286
|
+
// the progress section itself.
|
|
287
|
+
const prData = await quietProbe($)`gh pr view ${prNumber} --repo ${owner}/${repo} --json body`;
|
|
284
288
|
const prInfo = JSON.parse(prData.stdout);
|
|
285
289
|
let currentBody = prInfo.body || '';
|
|
286
290
|
|
|
@@ -65,7 +65,8 @@ export const checkExistingForkOfRoot = async rootRepo => {
|
|
|
65
65
|
// not to the shell, and command-stream quotes interpolated values itself - so
|
|
66
66
|
// interpolating inside the quotes would leak shell quotes into the comparison.
|
|
67
67
|
const forkFilter = `.[] | select(.owner.login == ${JSON.stringify(currentUser)}) | .full_name`;
|
|
68
|
-
|
|
68
|
+
// Issue #2135: `mirror: false` - see the fork-name lookup below.
|
|
69
|
+
const forksResult = await lib.ghCmdRetry(() => $(QUIET_PROBE)`gh api repos/${rootRepo}/forks --paginate --jq ${forkFilter}`, { label: `check forks of ${rootRepo}` });
|
|
69
70
|
if (forksResult.code !== 0) return null;
|
|
70
71
|
|
|
71
72
|
const forks = forksResult.stdout
|
|
@@ -1225,7 +1226,9 @@ export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isCont
|
|
|
1225
1226
|
// Issue #2119: the double quotes here are jq syntax, so the expression is
|
|
1226
1227
|
// built in JS and interpolated as one already-escaped argument.
|
|
1227
1228
|
const forkNameFilter = `.[] | select(.owner.login == ${JSON.stringify(prForkOwner)}) | .name`;
|
|
1228
|
-
|
|
1229
|
+
// Issue #2135: `mirror: false` - a popular repository has thousands of
|
|
1230
|
+
// forks, and only the matching name is used.
|
|
1231
|
+
const forksResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/forks --paginate --jq ${forkNameFilter}`;
|
|
1229
1232
|
if (forksResult.code === 0 && forksResult.stdout) {
|
|
1230
1233
|
const forkName = forksResult.stdout.toString().trim().split('\n')[0]; // Take first match
|
|
1231
1234
|
if (forkName) {
|
|
@@ -407,7 +407,9 @@ export const cleanupClaudeFile = async (tempDir, branchName, claudeCommitHash =
|
|
|
407
407
|
// APPROACH 3: Check for modifications before reverting (proactive detection)
|
|
408
408
|
// This is the main strategy - detect if the file was modified after initial commit
|
|
409
409
|
await log(` Checking if ${fileName} was modified since initial commit...`, { verbose: true });
|
|
410
|
-
|
|
410
|
+
// Issue #2135: `mirror: false`. Only "is it non-empty" is asked here, and
|
|
411
|
+
// the answer is a file's whole diff.
|
|
412
|
+
const diffResult = await $({ cwd: tempDir, ...QUIET_PROBE })`git diff ${commitToRevert} HEAD -- ${fileName} 2>&1`;
|
|
411
413
|
|
|
412
414
|
if (diffResult.stdout && diffResult.stdout.trim()) {
|
|
413
415
|
// File was modified after initial commit - use manual approach to avoid conflicts
|
|
@@ -746,7 +748,8 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
746
748
|
// First, get all PRs from our branch
|
|
747
749
|
// IMPORTANT: Use --state all to find PRs that may have been merged during the session (Issue #1008)
|
|
748
750
|
// Without --state all, gh pr list only returns OPEN PRs, missing merged ones
|
|
749
|
-
|
|
751
|
+
// Issue #2135: `mirror: false` - the pull requests found are named below.
|
|
752
|
+
const allBranchPrsResult = await $(QUIET_PROBE)`gh pr list --repo ${owner}/${repo} --head ${branchName} --state all --json number,url,createdAt,headRefName,title,state,updatedAt,isDraft`;
|
|
750
753
|
|
|
751
754
|
if (allBranchPrsResult.code !== 0) {
|
|
752
755
|
await log(' ⚠️ Failed to check pull requests');
|
|
@@ -819,7 +822,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
|
|
|
819
822
|
// "1 file(s) modified, 1 line(s) added" for a pull request that
|
|
820
823
|
// changed nothing, because the stats were never checked for being
|
|
821
824
|
// empty.
|
|
822
|
-
const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber: pr.number,
|
|
825
|
+
const changeStats = await getPullRequestChangeStats({ owner, repo, prNumber: pr.number, $, log });
|
|
823
826
|
if (!changeStats.hasChanges) {
|
|
824
827
|
await log(` ⚠️ PR #${pr.number} has an empty diff - the description will say so instead of claiming changes`, { level: 'warning' });
|
|
825
828
|
}
|
|
@@ -948,7 +951,9 @@ Fixes ${issueRef}
|
|
|
948
951
|
|
|
949
952
|
// Get all comments and filter them
|
|
950
953
|
// Use --paginate to get all comments - GitHub API returns max 30 per page by default
|
|
951
|
-
|
|
954
|
+
// Issue #2135: `mirror: false` - the counts below are the report; the raw
|
|
955
|
+
// answer is every comment body on the issue.
|
|
956
|
+
const allCommentsResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
|
|
952
957
|
|
|
953
958
|
if (allCommentsResult.code !== 0) {
|
|
954
959
|
await log(' ⚠️ Failed to check comments');
|
|
@@ -1209,7 +1214,7 @@ export const checkForAiCreatedComments = async (sessionStartTime, owner, repo, p
|
|
|
1209
1214
|
// Check comments on the PR first (if we have a PR)
|
|
1210
1215
|
if (prNumber) {
|
|
1211
1216
|
// Check PR conversation comments
|
|
1212
|
-
const prCommentsResult = await
|
|
1217
|
+
const prCommentsResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
|
|
1213
1218
|
if (prCommentsResult.code === 0) {
|
|
1214
1219
|
const prComments = JSON.parse(prCommentsResult.stdout.toString().trim() || '[]');
|
|
1215
1220
|
const newPrComments = filterNewAiComments(prComments, 'pr');
|
|
@@ -1220,7 +1225,7 @@ export const checkForAiCreatedComments = async (sessionStartTime, owner, repo, p
|
|
|
1220
1225
|
}
|
|
1221
1226
|
|
|
1222
1227
|
// Check PR review comments (inline code comments)
|
|
1223
|
-
const reviewCommentsResult = await
|
|
1228
|
+
const reviewCommentsResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`;
|
|
1224
1229
|
if (reviewCommentsResult.code === 0) {
|
|
1225
1230
|
const reviewComments = JSON.parse(reviewCommentsResult.stdout.toString().trim() || '[]');
|
|
1226
1231
|
const newReviewComments = filterNewAiComments(reviewComments, 'review');
|
|
@@ -1233,7 +1238,7 @@ export const checkForAiCreatedComments = async (sessionStartTime, owner, repo, p
|
|
|
1233
1238
|
|
|
1234
1239
|
// Check issue comments (if different from PR number or no PR)
|
|
1235
1240
|
if (issueNumber && issueNumber !== prNumber) {
|
|
1236
|
-
const issueCommentsResult = await
|
|
1241
|
+
const issueCommentsResult = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
|
|
1237
1242
|
if (issueCommentsResult.code === 0) {
|
|
1238
1243
|
const issueComments = JSON.parse(issueCommentsResult.stdout.toString().trim() || '[]');
|
|
1239
1244
|
const newIssueComments = filterNewAiComments(issueComments, 'issue');
|
|
@@ -1413,7 +1418,7 @@ export const maybeAttachWorkingSessionSummary = async ({ argv, resultSummary, wo
|
|
|
1413
1418
|
: null);
|
|
1414
1419
|
// Issue #2119: a summary posted on a pull request that changed nothing must
|
|
1415
1420
|
// say so, instead of reading as a report of completed work.
|
|
1416
|
-
const changeStats = prNumber ? await getPullRequestChangeStats({ owner, repo, prNumber,
|
|
1421
|
+
const changeStats = prNumber ? await getPullRequestChangeStats({ owner, repo, prNumber, $, log }) : null;
|
|
1417
1422
|
|
|
1418
1423
|
// Issue #2132: the summary carries no cost/budget block. `resolvedBudgetStatsData`
|
|
1419
1424
|
// is computed only so the caller can reuse it for this session's log comment.
|
package/src/task.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
6
7
|
import { promises as fs } from 'fs';
|
|
7
8
|
import { buildStartAgentArgs, resolveStartAgentCommand } from './task.agent-command.lib.mjs';
|
|
8
9
|
import { getDefaultTaskModel, parseTaskArguments } from './task.config.lib.mjs';
|
|
@@ -112,8 +113,8 @@ function runCommand(command, args, options = {}) {
|
|
|
112
113
|
child.on('error', error => {
|
|
113
114
|
resolve({ code: 1, stdout, stderr: stderr || error.message });
|
|
114
115
|
});
|
|
115
|
-
child.on('close', code => {
|
|
116
|
-
resolve({ code, stdout, stderr });
|
|
116
|
+
child.on('close', (code, signal) => {
|
|
117
|
+
resolve({ code, stdout, stderr, signal });
|
|
117
118
|
});
|
|
118
119
|
});
|
|
119
120
|
}
|
|
@@ -122,7 +123,8 @@ async function commandOutput(command, args, options = {}) {
|
|
|
122
123
|
const result = await runCommand(command, args, options);
|
|
123
124
|
if (result.code !== 0) {
|
|
124
125
|
const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
|
|
125
|
-
|
|
126
|
+
// Issue #2135: `describeChildExit` names a signal instead of "code null".
|
|
127
|
+
throw new Error(output || describeChildExit({ command, code: result.code, signal: result.signal }));
|
|
126
128
|
}
|
|
127
129
|
return result.stdout.trim();
|
|
128
130
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
2
3
|
import { promisify } from 'util';
|
|
3
4
|
import { exec as execCallback } from 'child_process';
|
|
4
5
|
import { t } from './i18n.lib.mjs';
|
|
@@ -57,7 +58,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
|
|
|
57
58
|
});
|
|
58
59
|
});
|
|
59
60
|
|
|
60
|
-
child.on('close', code => {
|
|
61
|
+
child.on('close', (code, signal) => {
|
|
61
62
|
if (code === 0) {
|
|
62
63
|
resolve({
|
|
63
64
|
success: true,
|
|
@@ -67,7 +68,9 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
|
|
|
67
68
|
resolve({
|
|
68
69
|
success: false,
|
|
69
70
|
output: stdout,
|
|
70
|
-
|
|
71
|
+
// Issue #2135: name the signal, so an out-of-memory abort is not
|
|
72
|
+
// reported as "code null".
|
|
73
|
+
error: stderr || describeChildExit({ command: 'Command', code, signal }),
|
|
71
74
|
});
|
|
72
75
|
}
|
|
73
76
|
});
|