@link-assistant/hive-mind 2.11.7 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/src/bidirectional-interactive.lib.mjs +6 -2
  4. package/src/child-exit.lib.mjs +107 -0
  5. package/src/contributing-guidelines.lib.mjs +19 -6
  6. package/src/development-log.lib.mjs +82 -5
  7. package/src/fix.ci-cd-issue.lib.mjs +5 -3
  8. package/src/fix.mjs +5 -2
  9. package/src/github-entity-validation.lib.mjs +4 -1
  10. package/src/github.lib.mjs +3 -3
  11. package/src/hive.mjs +15 -21
  12. package/src/isolation-runner.lib.mjs +5 -2
  13. package/src/lib.mjs +21 -0
  14. package/src/locales/en.lino +10 -0
  15. package/src/locales/hi.lino +10 -0
  16. package/src/locales/ru.lino +10 -0
  17. package/src/locales/zh.lino +10 -0
  18. package/src/log-growth.lib.mjs +94 -0
  19. package/src/option-suggestions.lib.mjs +2 -0
  20. package/src/pull-request-changes.lib.mjs +94 -24
  21. package/src/review.mjs +12 -3
  22. package/src/session-kill-diagnostics.lib.mjs +388 -0
  23. package/src/session-kill-policy.lib.mjs +96 -0
  24. package/src/session-kill-recovery.lib.mjs +256 -0
  25. package/src/session-kill-resume.lib.mjs +175 -0
  26. package/src/session-monitor.kill-sections.lib.mjs +198 -0
  27. package/src/session-monitor.lib.mjs +97 -2
  28. package/src/session-monitor.oom.lib.mjs +148 -0
  29. package/src/session-monitor.stale-executing.lib.mjs +6 -27
  30. package/src/session-resume.lib.mjs +28 -2
  31. package/src/solve.auto-continue.lib.mjs +6 -2
  32. package/src/solve.auto-merge.lib.mjs +1 -1
  33. package/src/solve.config.lib.mjs +14 -0
  34. package/src/solve.keep-working.lib.mjs +7 -2
  35. package/src/solve.minimal-restart-prompt.lib.mjs +11 -3
  36. package/src/solve.preparation.lib.mjs +5 -1
  37. package/src/solve.progress-monitoring.lib.mjs +5 -1
  38. package/src/solve.repository.lib.mjs +5 -2
  39. package/src/solve.results.lib.mjs +13 -8
  40. package/src/task.mjs +5 -3
  41. package/src/telegram-bot.mjs +3 -1
  42. package/src/telegram-command-execution.lib.mjs +5 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
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
+
31
+ ## 2.11.8
32
+
33
+ ### Patch Changes
34
+
35
+ - 881e604: Stop announcing kills that did not happen, and say exactly what happened when one did. Docker sets `State.OOMKilled` when any process in the container cgroup is OOM-killed, so a container whose helper process died keeps running and can still exit 0 — Hive Mind treated the flag as terminal and reported a task as "killed — out of memory or forced kill (SIGKILL)" while it went on working for another 3.5 hours and merged its pull request with nothing said there. The flag is now an observation: the log footer wins, then container liveness, and only a dead container with no footer is reported as killed.
36
+
37
+ Every kill now carries a diagnosis built from evidence already collected — the resource markers in the working-session log, cgroup v2 `memory.events`, `/proc/meminfo`, disk usage and the kernel OOM report — so the message names out of memory, disk exhaustion or a forced kill instead of guessing, and names the process the kernel killed. A session that survived the event completes with a `recovered from out of memory` / `recovered from forced kill` warning (en/ru/zh/hi) instead of reading as a plain success, and the same report is posted to the pull request; the intermediate working-session log is uploaded alongside it only when `--attach-logs` is enabled. Behaviour is one option honoured identically by the bot and by `solve`: `--on-session-kill=report|resume` (env `HIVE_MIND_ON_SESSION_KILL`), defaulting to today's `report`. Under `resume`, a killed session is restarted through the same isolation backend from the last tool session id found in its own log, the new session is tracked like any other, and both the Telegram completion and the pull-request notice name it; `--session-kill-resume-attempts` (default 1) caps how many recovery sessions one killed session may produce, and a session stopped on purpose is never restarted.
38
+
3
39
  ## 2.11.7
4
40
 
5
41
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.7",
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
  });
@@ -658,6 +658,16 @@ en
658
658
  label "Duration"
659
659
  session
660
660
  label "Session"
661
+ recovered
662
+ oom "recovered from out of memory"
663
+ kill "recovered from forced kill"
664
+ at "The event was observed at {{observedAt}}; the work session kept running and completed."
665
+ resumed "A new working session was started to recover from the kill."
666
+ kill
667
+ cause "Cause"
668
+ diagnostics "Kill diagnostics"
669
+ resumed "🔄 A new working session was started to recover from this kill: {{sessionId}}"
670
+ resumed_attempt "🔄 A new working session was started to recover from this kill (attempt {{attempt}}): {{sessionId}}"
661
671
  isolation
662
672
  label "Isolation"
663
673
  error
@@ -658,6 +658,16 @@ hi
658
658
  label "अवधि"
659
659
  session
660
660
  label "सत्र"
661
+ recovered
662
+ oom "मेमोरी समाप्त होने से पुनर्प्राप्त"
663
+ kill "जबरन समाप्ति से पुनर्प्राप्त"
664
+ at "यह घटना {{observedAt}} पर देखी गई; कार्य सत्र चलता रहा और पूरा हुआ।"
665
+ resumed "समाप्ति से पुनर्प्राप्ति हेतु नया कार्य सत्र शुरू किया गया।"
666
+ kill
667
+ cause "कारण"
668
+ diagnostics "समाप्ति निदान"
669
+ resumed "🔄 इस समाप्ति से पुनर्प्राप्ति हेतु नया कार्य सत्र शुरू किया गया: {{sessionId}}"
670
+ resumed_attempt "🔄 इस समाप्ति से पुनर्प्राप्ति हेतु नया कार्य सत्र शुरू किया गया (प्रयास {{attempt}}): {{sessionId}}"
661
671
  isolation
662
672
  label "Isolation"
663
673
  error