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