@link-assistant/hive-mind 2.15.2 → 2.16.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.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/bot-lifecycle.lib.mjs +8 -1
- package/src/child-exit.lib.mjs +53 -1
- package/src/claude.session-tokens.lib.mjs +10 -8
- package/src/claude.session-transcript-repair.lib.mjs +65 -34
- package/src/codex.lib.mjs +11 -1
- package/src/development-log.lib.mjs +22 -7
- package/src/github-error-reporter.lib.mjs +84 -2
- package/src/github.lib.mjs +212 -152
- package/src/log-bounded-read.lib.mjs +411 -0
- package/src/log-sanitize-stream.lib.mjs +267 -0
- package/src/log-sanitize-worker-entry.mjs +31 -0
- package/src/log-sanitize-worker.lib.mjs +186 -0
- package/src/log-upload.lib.mjs +16 -4
- package/src/session-completion-state.lib.mjs +124 -0
- package/src/session-kill-diagnostics.lib.mjs +73 -10
- package/src/session-kill-policy.lib.mjs +18 -7
- package/src/session-kill-resume.lib.mjs +5 -2
- package/src/session-monitor.lib.mjs +132 -9
- package/src/session-store.lib.mjs +15 -1
- package/src/solve.config.lib.mjs +5 -2
- package/src/solve.resource-diagnostics.lib.mjs +71 -4
- package/src/telegram-log-command.lib.mjs +7 -3
- package/src/telegram-terminal-watch-command.lib.mjs +9 -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 };
|
package/src/log-upload.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
161
|
-
|
|
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,
|
|
@@ -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,21 @@ 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
|
+
|
|
40
57
|
/** Disk is considered full at or above this used percentage… */
|
|
41
58
|
export const DISK_FULL_USED_PERCENT = 95;
|
|
42
59
|
/** …or below this much free space, whichever triggers first. */
|
|
@@ -66,6 +83,22 @@ export function selectLastMemoryResourceMarker(parsed) {
|
|
|
66
83
|
return null;
|
|
67
84
|
}
|
|
68
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The most recent marker that carries a V8 heap reading (issue #2189). Older
|
|
88
|
+
* logs have none — the heap fields were added with this fix — so every caller
|
|
89
|
+
* must tolerate `null`.
|
|
90
|
+
*
|
|
91
|
+
* @param {{markers: Array}|null} parsed
|
|
92
|
+
* @returns {Object|null}
|
|
93
|
+
*/
|
|
94
|
+
export function selectLastHeapResourceMarker(parsed) {
|
|
95
|
+
const markers = Array.isArray(parsed?.markers) ? parsed.markers : [];
|
|
96
|
+
for (let i = markers.length - 1; i >= 0; i--) {
|
|
97
|
+
if (finite(markers[i]?.memory?.processHeapUsedBytes) !== null) return markers[i];
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
69
102
|
/**
|
|
70
103
|
* The most recent marker that carries usable disk data.
|
|
71
104
|
*
|
|
@@ -240,6 +273,7 @@ function describeDisk(disk, timestamp) {
|
|
|
240
273
|
export function describeKillCause({ logText = null, resourceMarkers = null, oomKilled = false, exitCode = null, system = null, stopRequestedByUser = false } = {}) {
|
|
241
274
|
const parsed = resourceMarkers || (logText ? parseResourceMarkers(logText) : { markers: [], byPhase: {} });
|
|
242
275
|
const memoryMarker = selectLastMemoryResourceMarker(parsed);
|
|
276
|
+
const heapMarker = selectLastHeapResourceMarker(parsed);
|
|
243
277
|
const diskMarker = selectLastDiskResourceMarker(parsed);
|
|
244
278
|
const memory = memoryMarker?.memory || null;
|
|
245
279
|
const disk = diskMarker?.disk || null;
|
|
@@ -249,6 +283,12 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
|
|
|
249
283
|
const evidence = [];
|
|
250
284
|
const memoryLine = describeMemory(memory, memoryMarker?.timestamp || null);
|
|
251
285
|
if (memoryLine) evidence.push(`last session memory reading — ${memoryLine} (phase \`${memoryMarker.phase}\`)`);
|
|
286
|
+
// Issue #2189: report the heap against its own limit, which is the number that
|
|
287
|
+
// decides a "JavaScript heap out of memory" abort — host RAM does not.
|
|
288
|
+
const heapMemory = heapMarker?.memory || null;
|
|
289
|
+
const heapUsedPercent = finite(heapMemory?.processHeapUsedPercent);
|
|
290
|
+
const heapLine = heapMemory ? `${formatHeapUsage(heapMemory)}${heapMarker?.timestamp ? ` at ${heapMarker.timestamp}` : ''}` : null;
|
|
291
|
+
if (heapLine) evidence.push(`last session V8 heap reading — ${heapLine} (phase \`${heapMarker.phase}\`)`);
|
|
252
292
|
const diskLine = describeDisk(disk, diskMarker?.timestamp || null);
|
|
253
293
|
if (diskLine) evidence.push(`last session ${diskLine} (phase \`${diskMarker.phase}\`)`);
|
|
254
294
|
if (oomKilled) evidence.push('container reports `State.OOMKilled = true` (an OOM event hit the container cgroup)');
|
|
@@ -260,8 +300,21 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
|
|
|
260
300
|
evidence.push(`kernel OOM killer terminated \`${victim.comm || 'unknown'}\` (pid ${victim.pid ?? '?'})`);
|
|
261
301
|
}
|
|
262
302
|
|
|
303
|
+
// Issue #2189: the ground truth for a runtime that exhausted its OWN heap is
|
|
304
|
+
// the fatal line it printed, not the cgroup counters — those legitimately read
|
|
305
|
+
// zero for a V8 self-abort. Only an abnormal ending is allowed to be upgraded,
|
|
306
|
+
// so a log that merely mentions the phrase cannot manufacture a diagnosis.
|
|
307
|
+
const abnormalExit = exitCode === null || exitCode !== 0;
|
|
308
|
+
const fatalMemoryMarker = abnormalExit ? findFatalMemoryMarker(logText) : null;
|
|
309
|
+
if (fatalMemoryMarker) {
|
|
310
|
+
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)`);
|
|
311
|
+
}
|
|
312
|
+
|
|
263
313
|
const ratio = memoryRatio(memory);
|
|
264
314
|
const memoryExhausted = ratio !== null && ratio <= MEMORY_EXHAUSTED_AVAILABLE_RATIO;
|
|
315
|
+
// A heap already at the limit is only evidence of a kill when the session
|
|
316
|
+
// actually ended abnormally — a healthy run may legitimately end near its cap.
|
|
317
|
+
const heapExhausted = abnormalExit && heapUsedPercent !== null && heapUsedPercent >= HEAP_EXHAUSTED_PERCENT;
|
|
265
318
|
const diskUsedPercent = finite(disk?.usedPercent);
|
|
266
319
|
const diskAvailable = finite(disk?.availableBytes);
|
|
267
320
|
const diskFull = (diskUsedPercent !== null && diskUsedPercent >= DISK_FULL_USED_PERCENT) || (diskAvailable !== null && diskAvailable <= DISK_FULL_AVAILABLE_BYTES);
|
|
@@ -269,7 +322,7 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
|
|
|
269
322
|
let cause = KILL_CAUSE_UNKNOWN;
|
|
270
323
|
if (stopRequestedByUser) {
|
|
271
324
|
cause = KILL_CAUSE_FORCED_KILL;
|
|
272
|
-
} else if (victims.length > 0 || (cgroupOomKills !== null && cgroupOomKills > 0) || oomKilled || memoryExhausted) {
|
|
325
|
+
} else if (victims.length > 0 || (cgroupOomKills !== null && cgroupOomKills > 0) || oomKilled || memoryExhausted || fatalMemoryMarker || heapExhausted) {
|
|
273
326
|
cause = KILL_CAUSE_OUT_OF_MEMORY;
|
|
274
327
|
} else if (diskFull) {
|
|
275
328
|
cause = KILL_CAUSE_DISK_FULL;
|
|
@@ -278,7 +331,16 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
|
|
|
278
331
|
}
|
|
279
332
|
|
|
280
333
|
let summary;
|
|
281
|
-
if (cause === KILL_CAUSE_OUT_OF_MEMORY) {
|
|
334
|
+
if (cause === KILL_CAUSE_OUT_OF_MEMORY && fatalMemoryMarker && victims.length === 0 && !oomKilled && !(cgroupOomKills > 0)) {
|
|
335
|
+
// Issue #2189: appending "10.3 GB of 11.7 GB RAM available" to "out of
|
|
336
|
+
// memory" reads as a contradiction. For a runtime self-abort the host being
|
|
337
|
+
// healthy is the whole point, so say which limit was actually hit.
|
|
338
|
+
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})` : ''}`;
|
|
339
|
+
} else if (cause === KILL_CAUSE_OUT_OF_MEMORY && heapExhausted && victims.length === 0 && !oomKilled && !(cgroupOomKills > 0) && !memoryExhausted) {
|
|
340
|
+
// Same shape as the fatal-marker case, but reconstructed from telemetry when
|
|
341
|
+
// the fatal line itself was lost (truncated tail, killed before flushing).
|
|
342
|
+
summary = `out of memory — the runtime's own heap was exhausted: ${heapLine}${memoryLine ? ` (host memory was fine: ${memoryLine})` : ''}`;
|
|
343
|
+
} else if (cause === KILL_CAUSE_OUT_OF_MEMORY) {
|
|
282
344
|
const victim = victims.length > 0 ? `, kernel OOM killer terminated \`${victims[victims.length - 1].comm || 'unknown'}\` (pid ${victims[victims.length - 1].pid ?? '?'})` : '';
|
|
283
345
|
summary = `out of memory${memoryLine ? ` — ${memoryLine}` : ''}${victim}`;
|
|
284
346
|
} else if (cause === KILL_CAUSE_DISK_FULL) {
|
|
@@ -290,7 +352,7 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
|
|
|
290
352
|
summary = 'unknown — no resource marker, cgroup counter or kernel OOM report was available';
|
|
291
353
|
}
|
|
292
354
|
|
|
293
|
-
return { cause, summary, evidence, memory, disk, victims };
|
|
355
|
+
return { cause, summary, evidence, memory, heap: heapMemory, heapUsedPercent, disk, victims, fatalMemoryMarker };
|
|
294
356
|
}
|
|
295
357
|
|
|
296
358
|
/**
|
|
@@ -367,15 +429,16 @@ export function formatKillResumeSection({ sessionId = null, attempt = null, maxA
|
|
|
367
429
|
* @param {Object} [options]
|
|
368
430
|
* @returns {Promise<{section: string, diagnosis: Object|null}>}
|
|
369
431
|
*/
|
|
370
|
-
export async function buildKillDiagnosticsSection(logPath, { verbose = false, readFile = fsPromises.readFile, oomKilled = false, exitCode = null, stopRequestedByUser = false, locale = null, collectSystem = collectSystemKillDiagnostics } = {}) {
|
|
432
|
+
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 } = {}) {
|
|
371
433
|
try {
|
|
372
434
|
let logText = '';
|
|
373
435
|
if (logPath) {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
436
|
+
// Issue #2189: this used to be `readFile(logPath, 'utf8')`. The bot ran it
|
|
437
|
+
// once per monitor tick for a session it never marked handled, so a 134 MB
|
|
438
|
+
// transcript was pulled into the bot's own heap over and over. Everything
|
|
439
|
+
// this function needs — the `📈 [RESOURCES]` markers and the runtime's
|
|
440
|
+
// dying `FATAL ERROR` line — lives at the two ends of the transcript.
|
|
441
|
+
logText = await readLogTextBounded(logPath, { readFile, maxBytes: maxLogBytes, verbose });
|
|
379
442
|
}
|
|
380
443
|
const system = await collectSystem({ verbose });
|
|
381
444
|
const diagnosis = describeKillCause({ logText, oomKilled, exitCode, system, stopRequestedByUser });
|
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* - `resume
|
|
12
|
-
* started from the last tool session id, and BOTH
|
|
13
|
-
* ("recovered from out of memory" / "a new working session
|
|
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 =
|
|
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
|
|
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
* The restart is bounded by `--session-kill-resume-attempts` (default 1), so a
|
|
12
12
|
* job that reliably runs the host out of memory cannot storm the queue.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Issue #2189 made `resume` the default: a killed session that is only ever
|
|
15
|
+
* *offered* for resume is a session nobody resumes. `--on-session-kill=report`
|
|
16
|
+
* turns everything below back off, and `planKillRecovery` still returns
|
|
17
|
+
* `reason: 'policy-report'` in that case.
|
|
16
18
|
*
|
|
17
19
|
* @see https://github.com/link-assistant/hive-mind/issues/2134
|
|
20
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2189
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import { readLastSessionIdFromLog, planKilledSessionResume } from './session-resume.lib.mjs';
|