@link-assistant/hive-mind 2.15.2 → 2.17.0

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 (49) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +63 -5
  11. package/src/child-exit.lib.mjs +53 -1
  12. package/src/claude.session-tokens.lib.mjs +10 -8
  13. package/src/claude.session-transcript-repair.lib.mjs +65 -34
  14. package/src/cleanup.mjs +57 -3
  15. package/src/codex.lib.mjs +11 -1
  16. package/src/development-log.lib.mjs +22 -7
  17. package/src/disk-guard.lib.mjs +21 -1
  18. package/src/formal-ai-version.lib.mjs +10 -6
  19. package/src/github-error-reporter.lib.mjs +84 -2
  20. package/src/github.lib.mjs +212 -152
  21. package/src/instrument.mjs +12 -14
  22. package/src/instrument.sanitize.lib.mjs +52 -0
  23. package/src/isolation-runner.lib.mjs +56 -33
  24. package/src/isolation-runner.parsers.lib.mjs +29 -3
  25. package/src/isolation-runner.resume.lib.mjs +263 -0
  26. package/src/log-bounded-read.lib.mjs +411 -0
  27. package/src/log-sanitize-stream.lib.mjs +267 -0
  28. package/src/log-sanitize-worker-entry.mjs +31 -0
  29. package/src/log-sanitize-worker.lib.mjs +186 -0
  30. package/src/log-upload.lib.mjs +16 -4
  31. package/src/pull-request-changes.lib.mjs +1 -1
  32. package/src/session-completion-state.lib.mjs +124 -0
  33. package/src/session-kill-diagnostics.lib.mjs +117 -12
  34. package/src/session-kill-policy.lib.mjs +18 -7
  35. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  36. package/src/session-kill-resume.lib.mjs +48 -18
  37. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  38. package/src/session-monitor.lib.mjs +132 -9
  39. package/src/session-store.lib.mjs +15 -1
  40. package/src/solve.clone-errors.lib.mjs +86 -0
  41. package/src/solve.config.lib.mjs +5 -2
  42. package/src/solve.repository.lib.mjs +36 -63
  43. package/src/solve.resource-diagnostics.lib.mjs +105 -5
  44. package/src/start-command-cli.lib.mjs +60 -0
  45. package/src/telegram-bot.mjs +31 -91
  46. package/src/telegram-log-command.lib.mjs +7 -3
  47. package/src/telegram-overrides-validation.lib.mjs +73 -0
  48. package/src/telegram-terminal-watch-command.lib.mjs +9 -1
  49. package/src/working-session-summary.lib.mjs +1 -1
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Worker entry point for {@link sanitizeLogFileToFileBounded} (issue #2189).
5
+ *
6
+ * Runs the streaming publication sanitizer inside a worker thread whose old
7
+ * generation is capped by `resourceLimits`, so a residual blow-up in any of the
8
+ * sanitizer's regular expressions terminates *this thread* with
9
+ * `ERR_WORKER_OUT_OF_MEMORY` instead of aborting the whole run with
10
+ * `FATAL ERROR: Reached heap limit`.
11
+ *
12
+ * Protocol: post `{type:'ready'}` once the sanitizer module graph is loaded (the
13
+ * parent uses it to tell a start-up failure from a sanitize failure), then
14
+ * `{type:'done', stats}` or `{type:'error', ...}`.
15
+ *
16
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
17
+ */
18
+
19
+ import { parentPort, workerData } from 'node:worker_threads';
20
+ import { sanitizeLogFileToFile } from './log-sanitize-stream.lib.mjs';
21
+
22
+ if (!parentPort) throw new Error('log-sanitize-worker-entry must be started as a worker thread');
23
+
24
+ parentPort.postMessage({ type: 'ready' });
25
+
26
+ try {
27
+ const stats = await sanitizeLogFileToFile({ ...(workerData || {}) });
28
+ parentPort.postMessage({ type: 'done', stats });
29
+ } catch (error) {
30
+ parentPort.postMessage({ type: 'error', message: error?.message || String(error), name: error?.name || 'Error', code: error?.code || null });
31
+ }
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Bounded-heap execution of the streaming log sanitizer (issue #2189).
5
+ *
6
+ * {@link sanitizeLogFileToFile} is already memory-bounded by construction: it
7
+ * holds one block at a time and caps its hold-back. This module is the second
8
+ * line of defence issue #2189 asks for —
9
+ *
10
+ * > A worker with a bounded heap for the sanitize step would also contain any
11
+ * > residual blow-up instead of taking the whole run down.
12
+ *
13
+ * Node's `worker_threads` gives each worker its own V8 isolate with its own
14
+ * `resourceLimits`. Exceeding them terminates the worker with
15
+ * `ERR_WORKER_OUT_OF_MEMORY` and leaves the parent running, verified in
16
+ * `experiments/issue-2189-bounded-sanitize-worker.mjs`. So even if a future
17
+ * pattern, a pathological log, or a dependency upgrade reintroduces an
18
+ * unbounded allocation inside the sanitizer, the blast radius is one thread and
19
+ * one failed log upload — not the working session that already did its job.
20
+ *
21
+ * The worker is only worth its start-up cost on logs big enough to matter, so
22
+ * small logs run in-process ({@link DEFAULT_WORKER_THRESHOLD_BYTES}). If the
23
+ * worker cannot even start (no `worker_threads`, a module resolution failure,
24
+ * a restricted runtime) the sanitize falls back in-process, which is exactly
25
+ * the behaviour before this module existed. Once the worker has reported
26
+ * `ready`, failures are propagated instead: falling back in-process after the
27
+ * worker hit its heap limit would run the same blow-up in the parent.
28
+ *
29
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
30
+ */
31
+
32
+ import fsPromises from 'node:fs/promises';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { sanitizeLogFileToFile } from './log-sanitize-stream.lib.mjs';
35
+
36
+ /** Old-generation cap for the sanitize worker, in MiB. */
37
+ export const DEFAULT_WORKER_HEAP_MB = 512;
38
+
39
+ /** Logs at or above this size are sanitized in a bounded worker. */
40
+ export const DEFAULT_WORKER_THRESHOLD_BYTES = 16 * 1024 * 1024;
41
+
42
+ /** Environment variable that forces the worker off (`0`/`false`/`off`). */
43
+ export const WORKER_DISABLE_ENV = 'HIVE_MIND_SANITIZE_WORKER';
44
+
45
+ const WORKER_ENTRY_URL = new URL('./log-sanitize-worker-entry.mjs', import.meta.url);
46
+
47
+ /** Options whose values cannot be structured-cloned into a worker. */
48
+ const NON_CLONABLE_OPTIONS = ['sanitize', 'transform', 'onProgress', 'fsImpl'];
49
+
50
+ /**
51
+ * Whether the bounded worker should be used for a source of this size.
52
+ *
53
+ * @param {number} sourceSize - Bytes to sanitize
54
+ * @param {object} [options]
55
+ * @param {number} [options.thresholdBytes=DEFAULT_WORKER_THRESHOLD_BYTES]
56
+ * @param {object} [options.env=process.env]
57
+ * @returns {boolean}
58
+ */
59
+ export function shouldUseSanitizeWorker(sourceSize, options = {}) {
60
+ const { thresholdBytes = DEFAULT_WORKER_THRESHOLD_BYTES, env = process.env } = options;
61
+ const disabled = String(env?.[WORKER_DISABLE_ENV] ?? '')
62
+ .trim()
63
+ .toLowerCase();
64
+ if (disabled === '0' || disabled === 'false' || disabled === 'off' || disabled === 'no') return false;
65
+ if (!Number.isFinite(sourceSize)) return false;
66
+ return sourceSize >= thresholdBytes;
67
+ }
68
+
69
+ /**
70
+ * Run {@link sanitizeLogFileToFile} inside a worker with a bounded old generation.
71
+ *
72
+ * @param {object} options - Forwarded to {@link sanitizeLogFileToFile}
73
+ * @param {number} [options.workerHeapMb=DEFAULT_WORKER_HEAP_MB]
74
+ * @param {Function} [options.workerFactory] - `(url, opts) => Worker`, for tests
75
+ * @returns {Promise<object>} Sanitize stats, plus `{worker: true}`
76
+ */
77
+ export async function sanitizeLogFileInWorker(options = {}) {
78
+ const { workerHeapMb = DEFAULT_WORKER_HEAP_MB, workerFactory = null, ...sanitizeOptions } = options;
79
+ const createWorker = workerFactory || (await defaultWorkerFactory());
80
+
81
+ return await new Promise((resolve, reject) => {
82
+ let ready = false;
83
+ let settled = false;
84
+ const settle = (fn, value) => {
85
+ if (settled) return;
86
+ settled = true;
87
+ fn(value);
88
+ };
89
+
90
+ let worker;
91
+ try {
92
+ worker = createWorker(WORKER_ENTRY_URL, {
93
+ workerData: sanitizeOptions,
94
+ resourceLimits: { maxOldGenerationSizeMb: workerHeapMb },
95
+ });
96
+ } catch (error) {
97
+ error.sanitizeWorkerStarted = false;
98
+ settle(reject, error);
99
+ return;
100
+ }
101
+
102
+ worker.on('message', message => {
103
+ if (message?.type === 'ready') {
104
+ ready = true;
105
+ return;
106
+ }
107
+ if (message?.type === 'done') {
108
+ settle(resolve, { ...message.stats, worker: true });
109
+ worker.terminate().catch(() => {});
110
+ return;
111
+ }
112
+ if (message?.type === 'error') {
113
+ const error = new Error(message.message);
114
+ error.name = message.name || 'Error';
115
+ if (message.code) error.code = message.code;
116
+ error.sanitizeWorkerStarted = true;
117
+ settle(reject, error);
118
+ worker.terminate().catch(() => {});
119
+ }
120
+ });
121
+ worker.on('error', error => {
122
+ error.sanitizeWorkerStarted = ready;
123
+ settle(reject, error);
124
+ });
125
+ worker.on('exit', code => {
126
+ const error = new Error(`log sanitize worker exited with code ${code} before reporting a result`);
127
+ error.sanitizeWorkerStarted = ready;
128
+ settle(reject, error);
129
+ });
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Sanitize a log file to another file, in a bounded worker when it is large.
135
+ *
136
+ * Drop-in replacement for {@link sanitizeLogFileToFile}: same options, same
137
+ * stats, same fail-closed guarantee about the destination.
138
+ *
139
+ * @param {object} options - {@link sanitizeLogFileToFile} options
140
+ * @param {number} [options.thresholdBytes=DEFAULT_WORKER_THRESHOLD_BYTES]
141
+ * @param {number} [options.workerHeapMb=DEFAULT_WORKER_HEAP_MB]
142
+ * @param {Function} [options.onWorkerFallback] - `({reason, error}) => void`
143
+ * @returns {Promise<object>} Sanitize stats; `worker` is true when the worker ran
144
+ */
145
+ export async function sanitizeLogFileToFileBounded(options = {}) {
146
+ const { thresholdBytes = DEFAULT_WORKER_THRESHOLD_BYTES, workerHeapMb = DEFAULT_WORKER_HEAP_MB, workerFactory = null, onWorkerFallback = null, env = process.env, fsImpl = fsPromises, ...sanitizeOptions } = options;
147
+
148
+ let sourceSize = null;
149
+ try {
150
+ sourceSize = (await fsImpl.stat(sanitizeOptions.sourcePath)).size;
151
+ } catch {
152
+ // An unknown size routes in-process, which is the conservative choice.
153
+ }
154
+
155
+ // `workerData` crosses the thread boundary by structured clone, which cannot
156
+ // carry functions. A caller that customises the sanitize, the transform or the
157
+ // progress hook keeps the in-process path (still streaming, still bounded).
158
+ const clonable = NON_CLONABLE_OPTIONS.every(name => typeof sanitizeOptions[name] !== 'function');
159
+
160
+ if (!clonable || !shouldUseSanitizeWorker(sourceSize, { thresholdBytes, env })) {
161
+ return { ...(await sanitizeLogFileToFile({ ...sanitizeOptions, fsImpl })), worker: false };
162
+ }
163
+
164
+ try {
165
+ return await sanitizeLogFileInWorker({ ...sanitizeOptions, workerHeapMb, workerFactory });
166
+ } catch (error) {
167
+ if (error?.sanitizeWorkerStarted) throw error;
168
+ // The worker never got as far as loading the sanitizer, so nothing about
169
+ // memory has been learned; run in-process exactly as before.
170
+ if (onWorkerFallback) onWorkerFallback({ reason: 'worker-unavailable', error });
171
+ await fsImpl.unlink(sanitizeOptions.destPath).catch(() => {});
172
+ return { ...(await sanitizeLogFileToFile({ ...sanitizeOptions, fsImpl })), worker: false };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Resolve the real `worker_threads` Worker constructor, lazily.
178
+ *
179
+ * @returns {Promise<Function>} `(url, options) => Worker`
180
+ */
181
+ async function defaultWorkerFactory() {
182
+ const { Worker } = await import('node:worker_threads');
183
+ return (url, workerOptions) => new Worker(fileURLToPath(url), workerOptions);
184
+ }
185
+
186
+ export { WORKER_ENTRY_URL };
@@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { sanitizeForPublication } from './token-sanitization.lib.mjs';
7
+ import { sanitizeLogFileToFileBounded } from './log-sanitize-worker.lib.mjs';
7
8
 
8
9
  // Log upload module for hive-mind
9
10
  // Uses gh-upload-log for uploading log files to GitHub
@@ -151,14 +152,25 @@ export const uploadLogWithGhUploadLog = async ({ logFile, isPublic, description,
151
152
  let privateTempDirectory = null;
152
153
 
153
154
  try {
154
- const exactSourceBytes = await fs.readFile(logFile, 'utf8');
155
- const sanitizedLog = await sanitizeForPublication(exactSourceBytes);
156
155
  const sanitizedDescription = description ? await sanitizeForPublication(description) : description;
157
156
  privateTempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'hive-mind-log-upload-'));
158
157
  await fs.chmod(privateTempDirectory, 0o700);
159
158
  const privateLogFile = path.join(privateTempDirectory, 'sanitized.log');
160
- await fs.writeFile(privateLogFile, sanitizedLog, { encoding: 'utf8', mode: 0o600 });
161
- await fs.chmod(privateLogFile, 0o600);
159
+ // Issue #2189: this used to be readFile → sanitizeForPublication → writeFile,
160
+ // i.e. three full-size copies of the log in the heap at once. A 134 MB
161
+ // transcript reliably killed the run with "Reached heap limit". The streaming
162
+ // sanitizer holds one block (1 MiB) at a time, so cost no longer scales with
163
+ // log size. This is now the ONLY place `--attach-logs` sanitizes the log.
164
+ // Large logs additionally run in a heap-capped worker, so a residual blow-up
165
+ // costs one thread and one failed upload instead of the whole session.
166
+ const sanitizeStats = await sanitizeLogFileToFileBounded({
167
+ sourcePath: logFile,
168
+ destPath: privateLogFile,
169
+ onWorkerFallback: ({ error }) => log(` ⚠️ Sanitize worker unavailable (${error?.message || error}); sanitizing in-process`, { verbose: true }),
170
+ });
171
+ if (verbose) {
172
+ await log(` 🧼 Streamed sanitize: ${sanitizeStats.sourceSize} bytes in ${sanitizeStats.blocks} block(s)${sanitizeStats.forcedReleases > 0 ? `, ${sanitizeStats.forcedReleases} forced release(s)` : ''}${sanitizeStats.worker ? ' (bounded worker)' : ''}`, { verbose: true });
173
+ }
162
174
 
163
175
  const commandArgs = buildGhUploadLogArgs({
164
176
  logFile: privateLogFile,
@@ -106,7 +106,7 @@ const measureDiff = diff => {
106
106
  section = null;
107
107
  };
108
108
 
109
- for (let start = 0; start < diff.length; ) {
109
+ for (let start = 0; start < diff.length;) {
110
110
  let end = diff.indexOf('\n', start);
111
111
  if (end === -1) end = diff.length;
112
112
  const line = diff.slice(start, end);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Terminal, persisted "this session has been reported" state (issue #2189).
3
+ *
4
+ * The captured incident shows what happens without it. A `/solve` session was
5
+ * killed at 14:07:49Z; the monitor detected the completion, ran the full
6
+ * completion pipeline, and something late in that pipeline threw. The session
7
+ * was therefore kept in memory "so the completion notification can be retried",
8
+ * and every subsequent poll started the whole pipeline again — four
9
+ * `Session … has finished. Sending notification` lines, three
10
+ * `was killed; offering resume from last session` lines. Each of those cycles
11
+ *
12
+ * - re-resolved the linked pull request over the network,
13
+ * - re-scanned a 134 MB working-session log for the last tool session id,
14
+ * - re-stat'ed a 27 GB docker writable layer,
15
+ * - and re-sent the notification the user had already received.
16
+ *
17
+ * The bot's RSS climbed from 1.78 GB to 1.84 GB against a ~2 GB heap cap while
18
+ * it did so. The requirement from the issue is exact: "a killed session must
19
+ * reach a terminal, persisted, handled state after its notification is delivered
20
+ * once", and "per-cycle work must not be O(log size) — cache the recovered
21
+ * session id in the session record".
22
+ *
23
+ * This module owns both halves of that:
24
+ *
25
+ * 1. {@link markCompletionHandled} / {@link isCompletionHandled} — the
26
+ * handled latch. It is written to the durable session snapshot, so it also
27
+ * survives a bot restart between "notification delivered" and "session
28
+ * untracked": a reloaded session that was already reported is finalized
29
+ * silently instead of notifying the user a second time.
30
+ * 2. {@link resolveCachedLastToolSessionId} — a memoized, snapshot-backed
31
+ * read of the last `Session ID:` marker in the working-session log. The
32
+ * log is scanned at most once per session, and a scan that found nothing
33
+ * is remembered as such (an empty string) so a fruitless multi-gigabyte
34
+ * scan is never repeated either.
35
+ *
36
+ * Pure and dependency-light: the log reader and the clock are injectable, so
37
+ * every branch is testable without a real log or a real bot.
38
+ *
39
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
40
+ */
41
+
42
+ import { readLastSessionIdFromLog } from './session-resume.lib.mjs';
43
+
44
+ /**
45
+ * Fields this module writes on a tracked session. They are persisted (see
46
+ * `PERSISTABLE_FIELDS` in session-store.lib.mjs) precisely so the latch and the
47
+ * cache survive the restart that would otherwise replay the whole pipeline.
48
+ */
49
+ export const COMPLETION_STATE_FIELDS = ['completionNotifiedAt', 'completionExitCode', 'completionStatus', 'lastToolSessionId'];
50
+
51
+ /**
52
+ * Whether this session's completion notification was already delivered.
53
+ *
54
+ * @param {Object|null} sessionInfo
55
+ * @returns {boolean}
56
+ */
57
+ export function isCompletionHandled(sessionInfo) {
58
+ return typeof sessionInfo?.completionNotifiedAt === 'string' && sessionInfo.completionNotifiedAt.length > 0;
59
+ }
60
+
61
+ /**
62
+ * Latch this session as reported, recording the outcome that was reported so a
63
+ * later finalize does not have to re-derive it.
64
+ *
65
+ * Idempotent: a second call keeps the first timestamp, because the first
66
+ * delivery is the one the user saw.
67
+ *
68
+ * @param {Object} sessionInfo - Tracked session info (mutated in place)
69
+ * @param {Object} [options]
70
+ * @param {number|null} [options.exitCode] - Exit code that was reported
71
+ * @param {string|null} [options.status] - Terminal status that was reported
72
+ * @param {Function} [options.now] - Injectable clock returning a Date
73
+ * @returns {boolean} True when this call is the one that latched it
74
+ */
75
+ export function markCompletionHandled(sessionInfo, { exitCode = null, status = null, now = () => new Date() } = {}) {
76
+ if (!sessionInfo || typeof sessionInfo !== 'object') return false;
77
+ if (isCompletionHandled(sessionInfo)) return false;
78
+ sessionInfo.completionNotifiedAt = now().toISOString();
79
+ sessionInfo.completionExitCode = Number.isFinite(exitCode) ? exitCode : null;
80
+ sessionInfo.completionStatus = status || null;
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * The last tool session id for this session, scanning the working-session log at
86
+ * most once.
87
+ *
88
+ * A session that has been scanned carries a string: the id, or `''` for "the log
89
+ * has no usable marker". Both are answers, and neither is worth paying for
90
+ * twice — the scan walks the log backwards in bounded chunks, but on a 134 MB
91
+ * log with no marker in the tail that is still a full read of the file, per
92
+ * poll, per session.
93
+ *
94
+ * @param {Object} options
95
+ * @param {Object|null} options.sessionInfo - Tracked session info (mutated in place)
96
+ * @param {string|null} [options.logPath] - Working-session log to scan
97
+ * @param {boolean} [options.verbose]
98
+ * @param {Function} [options.readLastSessionId] - Override for tests
99
+ * @returns {{id: string|null, cached: boolean, scanned: boolean}}
100
+ */
101
+ export function resolveCachedLastToolSessionId({ sessionInfo = null, logPath = null, verbose = false, readLastSessionId = readLastSessionIdFromLog } = {}) {
102
+ const cached = sessionInfo?.lastToolSessionId;
103
+ if (typeof cached === 'string') {
104
+ if (verbose && cached) {
105
+ console.log(`[VERBOSE] session-completion-state: reusing cached last tool session id ${cached} (log not re-scanned)`);
106
+ }
107
+ return { id: cached || null, cached: true, scanned: false };
108
+ }
109
+ let id;
110
+ try {
111
+ id = readLastSessionId(logPath, { verbose }) || null;
112
+ } catch (error) {
113
+ if (verbose) {
114
+ console.log(`[VERBOSE] session-completion-state: could not read last tool session id from ${logPath}: ${error?.message || error}`);
115
+ }
116
+ // A read error is not an answer — leave the cache unset so a later poll,
117
+ // once the log is readable again, can still find the id.
118
+ return { id: null, cached: false, scanned: false };
119
+ }
120
+ if (sessionInfo && typeof sessionInfo === 'object') sessionInfo.lastToolSessionId = id || '';
121
+ return { id, cached: false, scanned: true };
122
+ }
123
+
124
+ export default { COMPLETION_STATE_FIELDS, isCompletionHandled, markCompletionHandled, resolveCachedLastToolSessionId };
@@ -26,7 +26,9 @@ import fsPromises from 'fs/promises';
26
26
  import { exec as execCallback } from 'child_process';
27
27
  import { promisify } from 'util';
28
28
  import { t } from './i18n.lib.mjs';
29
- import { formatBytes, parseResourceMarkers } from './solve.resource-diagnostics.lib.mjs';
29
+ import { formatBytes, formatHeapUsage, parseResourceMarkers } from './solve.resource-diagnostics.lib.mjs';
30
+ import { findFatalMemoryMarker } from './child-exit.lib.mjs';
31
+ import { readLogTextBounded } from './log-bounded-read.lib.mjs';
30
32
 
31
33
  const exec = promisify(execCallback);
32
34
 
@@ -37,6 +39,31 @@ export const KILL_CAUSE_UNKNOWN = 'unknown';
37
39
 
38
40
  /** Memory is considered exhausted below this share of total RAM still available. */
39
41
  export const MEMORY_EXHAUSTED_AVAILABLE_RATIO = 0.1;
42
+ /**
43
+ * How much of a session log the kill diagnosis is allowed to read (issue #2189).
44
+ * Split head/tail by {@link readLogTextBounded}: 512 KiB of the beginning and
45
+ * 512 KiB of the end, which covers both the resource markers and the fatal exit.
46
+ */
47
+ export const KILL_DIAGNOSTICS_LOG_BYTES = 1024 * 1024;
48
+ /**
49
+ * A V8 heap this far into its own limit counts as memory exhaustion on its own
50
+ * (issue #2189). The runtime aborts with "Reached heap limit" while the machine
51
+ * still reports gigabytes free, so the host-memory ratio above never fires; the
52
+ * heap markers are the only in-band signal, and the last one written before the
53
+ * abort is typically already deep in the red.
54
+ */
55
+ export const HEAP_EXHAUSTED_PERCENT = 90;
56
+
57
+ /**
58
+ * Prefix start-command 0.33.0 puts on every `exitReason` that reports memory
59
+ * exhaustion, whatever the mechanism — `memory-exhaustion (v8-heap-limit)`,
60
+ * `memory-exhaustion (kernel-oom-killer)`, `memory-exhaustion (go-runtime)`,
61
+ * `memory-exhaustion (allocation-failure)`. Matching the prefix rather than the
62
+ * exact strings means a new upstream mechanism is classified correctly without
63
+ * a Hive Mind release. See link-foundation/start#164 and #165.
64
+ */
65
+ export const UPSTREAM_MEMORY_EXHAUSTION_PREFIX = 'memory-exhaustion';
66
+
40
67
  /** Disk is considered full at or above this used percentage… */
41
68
  export const DISK_FULL_USED_PERCENT = 95;
42
69
  /** …or below this much free space, whichever triggers first. */
@@ -66,6 +93,22 @@ export function selectLastMemoryResourceMarker(parsed) {
66
93
  return null;
67
94
  }
68
95
 
96
+ /**
97
+ * The most recent marker that carries a V8 heap reading (issue #2189). Older
98
+ * logs have none — the heap fields were added with this fix — so every caller
99
+ * must tolerate `null`.
100
+ *
101
+ * @param {{markers: Array}|null} parsed
102
+ * @returns {Object|null}
103
+ */
104
+ export function selectLastHeapResourceMarker(parsed) {
105
+ const markers = Array.isArray(parsed?.markers) ? parsed.markers : [];
106
+ for (let i = markers.length - 1; i >= 0; i--) {
107
+ if (finite(markers[i]?.memory?.processHeapUsedBytes) !== null) return markers[i];
108
+ }
109
+ return null;
110
+ }
111
+
69
112
  /**
70
113
  * The most recent marker that carries usable disk data.
71
114
  *
@@ -235,11 +278,15 @@ function describeDisk(disk, timestamp) {
235
278
  * @param {number|null} [params.exitCode]
236
279
  * @param {Object|null} [params.system] - collectSystemKillDiagnostics() result
237
280
  * @param {boolean} [params.stopRequestedByUser] - The operator asked for the stop
281
+ * @param {boolean|null} [params.reportedMemoryExhausted] - `$ --status` `memoryExhausted` (start-command >= 0.33.0)
282
+ * @param {string|null} [params.reportedMemoryExhaustedReason] - `$ --status` `memoryExhaustedReason` (the evidence line)
283
+ * @param {string|null} [params.reportedExitReason] - `$ --status` `exitReason` hint, e.g. `memory-exhaustion (v8-heap-limit)`
238
284
  * @returns {{cause: string, summary: string, evidence: string[], memory: Object|null, disk: Object|null, victims: Array}}
239
285
  */
240
- export function describeKillCause({ logText = null, resourceMarkers = null, oomKilled = false, exitCode = null, system = null, stopRequestedByUser = false } = {}) {
286
+ export function describeKillCause({ logText = null, resourceMarkers = null, oomKilled = false, exitCode = null, system = null, stopRequestedByUser = false, reportedMemoryExhausted = null, reportedMemoryExhaustedReason = null, reportedExitReason = null } = {}) {
241
287
  const parsed = resourceMarkers || (logText ? parseResourceMarkers(logText) : { markers: [], byPhase: {} });
242
288
  const memoryMarker = selectLastMemoryResourceMarker(parsed);
289
+ const heapMarker = selectLastHeapResourceMarker(parsed);
243
290
  const diskMarker = selectLastDiskResourceMarker(parsed);
244
291
  const memory = memoryMarker?.memory || null;
245
292
  const disk = diskMarker?.disk || null;
@@ -249,6 +296,12 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
249
296
  const evidence = [];
250
297
  const memoryLine = describeMemory(memory, memoryMarker?.timestamp || null);
251
298
  if (memoryLine) evidence.push(`last session memory reading — ${memoryLine} (phase \`${memoryMarker.phase}\`)`);
299
+ // Issue #2189: report the heap against its own limit, which is the number that
300
+ // decides a "JavaScript heap out of memory" abort — host RAM does not.
301
+ const heapMemory = heapMarker?.memory || null;
302
+ const heapUsedPercent = finite(heapMemory?.processHeapUsedPercent);
303
+ const heapLine = heapMemory ? `${formatHeapUsage(heapMemory)}${heapMarker?.timestamp ? ` at ${heapMarker.timestamp}` : ''}` : null;
304
+ if (heapLine) evidence.push(`last session V8 heap reading — ${heapLine} (phase \`${heapMarker.phase}\`)`);
252
305
  const diskLine = describeDisk(disk, diskMarker?.timestamp || null);
253
306
  if (diskLine) evidence.push(`last session ${diskLine} (phase \`${diskMarker.phase}\`)`);
254
307
  if (oomKilled) evidence.push('container reports `State.OOMKilled = true` (an OOM event hit the container cgroup)');
@@ -260,8 +313,45 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
260
313
  evidence.push(`kernel OOM killer terminated \`${victim.comm || 'unknown'}\` (pid ${victim.pid ?? '?'})`);
261
314
  }
262
315
 
316
+ // Issue #2189: the ground truth for a runtime that exhausted its OWN heap is
317
+ // the fatal line it printed, not the cgroup counters — those legitimately read
318
+ // zero for a V8 self-abort. Only an abnormal ending is allowed to be upgraded,
319
+ // so a log that merely mentions the phrase cannot manufacture a diagnosis.
320
+ const abnormalExit = exitCode === null || exitCode !== 0;
321
+ const fatalMemoryMarker = abnormalExit ? findFatalMemoryMarker(logText) : null;
322
+ if (fatalMemoryMarker) {
323
+ evidence.push(`the ${fatalMemoryMarker.runtime} runtime aborted on its own heap limit: \`${fatalMemoryMarker.line}\` (a self-abort is invisible to \`docker inspect\` and to cgroup OOM counters)`);
324
+ }
325
+
326
+ // Issue #2189 also went upstream: start-command 0.33.0 (link-foundation/start
327
+ // #164, #165) performs the same tail scan inside `$` and reports it as
328
+ // `memoryExhausted` / `memoryExhaustedReason` / `exitReason`. Consuming it
329
+ // catches the case our own scan cannot: the fatal line scrolled out of the
330
+ // bounded window we read, but `$` saw it when the command exited. The local
331
+ // scan stays as defense in depth — these fields are absent on an older `$`.
332
+ const reportedExitReasonText = typeof reportedExitReason === 'string' && reportedExitReason.trim() ? reportedExitReason.trim() : null;
333
+ const reportedMemoryExhaustion = abnormalExit && (reportedMemoryExhausted === true || (reportedExitReasonText !== null && reportedExitReasonText.startsWith(UPSTREAM_MEMORY_EXHAUSTION_PREFIX)));
334
+ // `memory-exhaustion (v8-heap-limit)` → `v8-heap-limit`: the prefix is already
335
+ // said in words by the surrounding sentence, so only the mechanism is new.
336
+ const reportedMechanism =
337
+ reportedMemoryExhaustion && reportedExitReasonText
338
+ ? reportedExitReasonText
339
+ .slice(UPSTREAM_MEMORY_EXHAUSTION_PREFIX.length)
340
+ .trim()
341
+ .replace(/^\((.*)\)$/, '$1') || null
342
+ : null;
343
+ if (reportedMemoryExhaustion) {
344
+ const detail = reportedMemoryExhaustedReason ? `: \`${reportedMemoryExhaustedReason}\`` : '';
345
+ evidence.push(`\`$ --status\` reports memory exhaustion${reportedMechanism ? ` (\`${reportedMechanism}\`)` : ''}${detail}`);
346
+ } else if (reportedExitReasonText && abnormalExit) {
347
+ evidence.push(`\`$ --status\` reports \`exitReason = ${reportedExitReasonText}\``);
348
+ }
349
+
263
350
  const ratio = memoryRatio(memory);
264
351
  const memoryExhausted = ratio !== null && ratio <= MEMORY_EXHAUSTED_AVAILABLE_RATIO;
352
+ // A heap already at the limit is only evidence of a kill when the session
353
+ // actually ended abnormally — a healthy run may legitimately end near its cap.
354
+ const heapExhausted = abnormalExit && heapUsedPercent !== null && heapUsedPercent >= HEAP_EXHAUSTED_PERCENT;
265
355
  const diskUsedPercent = finite(disk?.usedPercent);
266
356
  const diskAvailable = finite(disk?.availableBytes);
267
357
  const diskFull = (diskUsedPercent !== null && diskUsedPercent >= DISK_FULL_USED_PERCENT) || (diskAvailable !== null && diskAvailable <= DISK_FULL_AVAILABLE_BYTES);
@@ -269,7 +359,7 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
269
359
  let cause = KILL_CAUSE_UNKNOWN;
270
360
  if (stopRequestedByUser) {
271
361
  cause = KILL_CAUSE_FORCED_KILL;
272
- } else if (victims.length > 0 || (cgroupOomKills !== null && cgroupOomKills > 0) || oomKilled || memoryExhausted) {
362
+ } else if (victims.length > 0 || (cgroupOomKills !== null && cgroupOomKills > 0) || oomKilled || memoryExhausted || fatalMemoryMarker || heapExhausted || reportedMemoryExhaustion) {
273
363
  cause = KILL_CAUSE_OUT_OF_MEMORY;
274
364
  } else if (diskFull) {
275
365
  cause = KILL_CAUSE_DISK_FULL;
@@ -278,7 +368,21 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
278
368
  }
279
369
 
280
370
  let summary;
281
- if (cause === KILL_CAUSE_OUT_OF_MEMORY) {
371
+ if (cause === KILL_CAUSE_OUT_OF_MEMORY && fatalMemoryMarker && victims.length === 0 && !oomKilled && !(cgroupOomKills > 0)) {
372
+ // Issue #2189: appending "10.3 GB of 11.7 GB RAM available" to "out of
373
+ // memory" reads as a contradiction. For a runtime self-abort the host being
374
+ // healthy is the whole point, so say which limit was actually hit.
375
+ summary = `out of memory — the ${fatalMemoryMarker.runtime} runtime hit its own heap limit, not the machine's: \`${fatalMemoryMarker.line}\`${memoryLine ? ` (host memory was fine: ${memoryLine})` : ''}`;
376
+ } else if (cause === KILL_CAUSE_OUT_OF_MEMORY && heapExhausted && victims.length === 0 && !oomKilled && !(cgroupOomKills > 0) && !memoryExhausted) {
377
+ // Same shape as the fatal-marker case, but reconstructed from telemetry when
378
+ // the fatal line itself was lost (truncated tail, killed before flushing).
379
+ summary = `out of memory — the runtime's own heap was exhausted: ${heapLine}${memoryLine ? ` (host memory was fine: ${memoryLine})` : ''}`;
380
+ } else if (cause === KILL_CAUSE_OUT_OF_MEMORY && reportedMemoryExhaustion && victims.length === 0 && !oomKilled && !(cgroupOomKills > 0) && !memoryExhausted) {
381
+ // Only `$` saw the evidence (our bounded window missed the fatal line).
382
+ // Quote what it saw rather than falling back to the host-memory phrasing,
383
+ // which would again read as a contradiction on a healthy machine.
384
+ summary = `out of memory — start-command reported memory exhaustion${reportedMechanism ? ` (${reportedMechanism})` : ''}${reportedMemoryExhaustedReason ? `: \`${reportedMemoryExhaustedReason}\`` : ''}${memoryLine ? ` (host memory was fine: ${memoryLine})` : ''}`;
385
+ } else if (cause === KILL_CAUSE_OUT_OF_MEMORY) {
282
386
  const victim = victims.length > 0 ? `, kernel OOM killer terminated \`${victims[victims.length - 1].comm || 'unknown'}\` (pid ${victims[victims.length - 1].pid ?? '?'})` : '';
283
387
  summary = `out of memory${memoryLine ? ` — ${memoryLine}` : ''}${victim}`;
284
388
  } else if (cause === KILL_CAUSE_DISK_FULL) {
@@ -290,7 +394,7 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
290
394
  summary = 'unknown — no resource marker, cgroup counter or kernel OOM report was available';
291
395
  }
292
396
 
293
- return { cause, summary, evidence, memory, disk, victims };
397
+ return { cause, summary, evidence, memory, heap: heapMemory, heapUsedPercent, disk, victims, fatalMemoryMarker, reportedMemoryExhaustion, reportedExitReason: reportedExitReasonText };
294
398
  }
295
399
 
296
400
  /**
@@ -367,18 +471,19 @@ export function formatKillResumeSection({ sessionId = null, attempt = null, maxA
367
471
  * @param {Object} [options]
368
472
  * @returns {Promise<{section: string, diagnosis: Object|null}>}
369
473
  */
370
- export async function buildKillDiagnosticsSection(logPath, { verbose = false, readFile = fsPromises.readFile, oomKilled = false, exitCode = null, stopRequestedByUser = false, locale = null, collectSystem = collectSystemKillDiagnostics } = {}) {
474
+ export async function buildKillDiagnosticsSection(logPath, { verbose = false, readFile = fsPromises.readFile, maxLogBytes = KILL_DIAGNOSTICS_LOG_BYTES, oomKilled = false, exitCode = null, stopRequestedByUser = false, locale = null, collectSystem = collectSystemKillDiagnostics, reportedMemoryExhausted = null, reportedMemoryExhaustedReason = null, reportedExitReason = null } = {}) {
371
475
  try {
372
476
  let logText = '';
373
477
  if (logPath) {
374
- try {
375
- logText = await readFile(logPath, 'utf8');
376
- } catch (readError) {
377
- if (verbose) console.log(`[VERBOSE] kill-diagnostics: could not read session log ${logPath}: ${readError?.message || readError}`);
378
- }
478
+ // Issue #2189: this used to be `readFile(logPath, 'utf8')`. The bot ran it
479
+ // once per monitor tick for a session it never marked handled, so a 134 MB
480
+ // transcript was pulled into the bot's own heap over and over. Everything
481
+ // this function needs the `📈 [RESOURCES]` markers and the runtime's
482
+ // dying `FATAL ERROR` line — lives at the two ends of the transcript.
483
+ logText = await readLogTextBounded(logPath, { readFile, maxBytes: maxLogBytes, verbose });
379
484
  }
380
485
  const system = await collectSystem({ verbose });
381
- const diagnosis = describeKillCause({ logText, oomKilled, exitCode, system, stopRequestedByUser });
486
+ const diagnosis = describeKillCause({ logText, oomKilled, exitCode, system, stopRequestedByUser, reportedMemoryExhausted, reportedMemoryExhaustedReason, reportedExitReason });
382
487
  if (verbose) console.log(`[VERBOSE] kill-diagnostics: cause=${diagnosis.cause} — ${diagnosis.summary}`);
383
488
  return { section: formatKillDiagnosticsSection(diagnosis, { locale }), diagnosis };
384
489
  } catch (error) {
@@ -5,25 +5,36 @@
5
5
  * identically — the Telegram completion message and the pull-request notice must
6
6
  * never disagree about what happened:
7
7
  *
8
- * - `report` (default, today's behaviour): the kill is terminal. The Telegram
9
- * message says the session was killed, with the diagnosed cause, and offers
10
- * the resume command. The pull request gets the same notice.
11
- * - `resume`: the kill is treated as recoverable. A new working session is
12
- * started from the last tool session id, and BOTH surfaces say so
13
- * ("recovered from out of memory" / "a new working session was started").
8
+ * - `report`: the kill is terminal. The Telegram message says the session was
9
+ * killed, with the diagnosed cause, and offers the resume command. The pull
10
+ * request gets the same notice.
11
+ * - `resume` (default since issue #2189): the kill is treated as recoverable.
12
+ * A new working session is started from the last tool session id, and BOTH
13
+ * surfaces say so ("recovered from out of memory" / "a new working session
14
+ * was started").
14
15
  *
15
16
  * Selected by `--on-session-kill=<policy>` or `HIVE_MIND_ON_SESSION_KILL`, with
16
17
  * the CLI flag winning over the environment. Nothing is removed by choosing one
17
18
  * over the other: `resume` still reports the kill and its cause, it just adds
18
19
  * the recovery, and log uploads stay gated on `--attach-logs` in both modes.
19
20
  *
21
+ * Why `resume` is the default (issue #2189): under `report` the bot only ever
22
+ * *offered* a resume command that a human had to notice and paste. In the
23
+ * captured incident the offer reached the operator six hours after the crash,
24
+ * and the work sat abandoned in between. "The bot should initiate the resume
25
+ * itself with context preserved" — so it does, bounded by
26
+ * `--session-kill-resume-attempts` (default 1) so a job that reliably dies still
27
+ * cannot storm. `--on-session-kill=report` restores the announce-only
28
+ * behaviour verbatim for anyone who wants it.
29
+ *
20
30
  * @see https://github.com/link-assistant/hive-mind/issues/2134
31
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
21
32
  */
22
33
 
23
34
  export const ON_SESSION_KILL_REPORT = 'report';
24
35
  export const ON_SESSION_KILL_RESUME = 'resume';
25
36
  export const ON_SESSION_KILL_POLICIES = [ON_SESSION_KILL_REPORT, ON_SESSION_KILL_RESUME];
26
- export const DEFAULT_ON_SESSION_KILL_POLICY = ON_SESSION_KILL_REPORT;
37
+ export const DEFAULT_ON_SESSION_KILL_POLICY = ON_SESSION_KILL_RESUME;
27
38
 
28
39
  export const ON_SESSION_KILL_ENV_VAR = 'HIVE_MIND_ON_SESSION_KILL';
29
40