@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
@@ -658,6 +658,16 @@ ru
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 "Изоляция"
663
673
  error
@@ -658,6 +658,16 @@ zh
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 "隔离"
663
673
  error
@@ -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 };
@@ -194,6 +194,8 @@ const KNOWN_OPTION_NAMES = [
194
194
  'auto-resume-on-limit-reset',
195
195
  'auto-resume-on-errors',
196
196
  'auto-close-pull-request-on-fail',
197
+ 'on-session-kill',
198
+ 'session-kill-resume-attempts',
197
199
  'auto-pull-request-creation',
198
200
  'auto-commit-uncommitted-changes',
199
201
  'auto-restart-on-uncommitted-changes',
@@ -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}`);