@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
|
@@ -25,7 +25,8 @@ import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifyS
|
|
|
25
25
|
import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
|
|
26
26
|
import { safeSendMessage, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
27
27
|
import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
|
|
28
|
-
import {
|
|
28
|
+
import { buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
|
|
29
|
+
import { readLogMarkerLines, readLogTextBounded, scanLogTextChunks } from './log-bounded-read.lib.mjs';
|
|
29
30
|
import { resolveFailedSessionPullRequestState } from './github-pr-state.lib.mjs';
|
|
30
31
|
// Issue #2117: a docker terminal failure that no anchored log footer corroborates may be an exit code start-command fabricated from the command's own output.
|
|
31
32
|
import { clearUnverifiedDockerTerminalMarker as clearUnverifiedDockerTerminalMarkerImpl, shouldDeferUnverifiedDockerTerminal as shouldDeferUnverifiedDockerTerminalImpl } from './session-monitor.docker-terminal.lib.mjs';
|
|
@@ -33,6 +34,8 @@ import { isDockerIsolation, sessionStartMs, resolveOomKilledState, resolveStaleE
|
|
|
33
34
|
// Issue #2134: kill-cause diagnostics + the matching pull-request notice.
|
|
34
35
|
import { buildKillCompletionSections, announceKillOnPullRequest } from './session-monitor.kill-sections.lib.mjs';
|
|
35
36
|
import { runKillRecoveryForCompletion } from './session-kill-resume.lib.mjs';
|
|
37
|
+
// Issue #2189: the handled latch + the memoized last-tool-session-id read that keep a completed session from replaying its whole completion pipeline on every poll.
|
|
38
|
+
import { isCompletionHandled, markCompletionHandled, resolveCachedLastToolSessionId } from './session-completion-state.lib.mjs';
|
|
36
39
|
import { createSessionRegistryQueries } from './session-monitor.queries.lib.mjs';
|
|
37
40
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
38
41
|
export { DOCKER_TERMINAL_FOOTER_GRACE_MS } from './session-monitor.docker-terminal.lib.mjs';
|
|
@@ -326,6 +329,8 @@ function normalizeSessionUrl(url) {
|
|
|
326
329
|
return url.replace(/#.*$/, '').replace(/\/+$/, '').toLowerCase();
|
|
327
330
|
}
|
|
328
331
|
const GITHUB_PULL_REQUEST_URL_RE = /https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([0-9]+)/g;
|
|
332
|
+
/** Marker prefix parsed by `parseDiskMarkers` (issue #1945/#1988), used to collect just those lines. */
|
|
333
|
+
const DISK_MARKER_LINE_RE = /📊 \[DISK\] /;
|
|
329
334
|
export function extractPullRequestUrlFromText(text, { owner = null, repo = null } = {}) {
|
|
330
335
|
if (!text) return null;
|
|
331
336
|
const expectedOwner = owner ? String(owner).toLowerCase() : null;
|
|
@@ -344,8 +349,12 @@ export function extractPullRequestUrlFromText(text, { owner = null, repo = null
|
|
|
344
349
|
async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = false, readFile = fs.readFile } = {}) {
|
|
345
350
|
if (!logPath) return null;
|
|
346
351
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
352
|
+
// Issue #2189: this used to be `readFile(logPath, 'utf8')`, run once per
|
|
353
|
+
// monitor tick for a session the bot never marked handled — a 134 MB
|
|
354
|
+
// transcript pulled into the bot's own heap over and over. The chunked scan
|
|
355
|
+
// still covers the whole log, one chunk at a time, and stops at the first
|
|
356
|
+
// matching URL (which is printed early, when the PR is created).
|
|
357
|
+
const pullRequestUrl = await scanLogTextChunks(logPath, text => extractPullRequestUrlFromText(text, { owner: ctx.owner, repo: ctx.repo }), { readFile, verbose });
|
|
349
358
|
if (pullRequestUrl && verbose) {
|
|
350
359
|
console.log(`[VERBOSE] Found PR ${pullRequestUrl} in completed session log ${logPath}`);
|
|
351
360
|
}
|
|
@@ -370,7 +379,10 @@ export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = fals
|
|
|
370
379
|
let logText = '';
|
|
371
380
|
if (logPath) {
|
|
372
381
|
try {
|
|
373
|
-
|
|
382
|
+
// Issue #2189: `parseDiskMarkers` only ever looks at `📊 [DISK]` lines,
|
|
383
|
+
// so collect those lines instead of the whole transcript. Cost is one
|
|
384
|
+
// chunk plus a few kilobytes of markers, whatever the log's size.
|
|
385
|
+
logText = await readLogMarkerLines(logPath, DISK_MARKER_LINE_RE, { readFile, verbose });
|
|
374
386
|
} catch (readError) {
|
|
375
387
|
if (verbose) {
|
|
376
388
|
console.log(`[VERBOSE] Could not read session log ${logPath} for disk diagnostics: ${readError?.message || readError}`);
|
|
@@ -402,7 +414,10 @@ export async function buildSubscriptionBlockedExtraSection(logPath, { verbose =
|
|
|
402
414
|
try {
|
|
403
415
|
let logText = '';
|
|
404
416
|
try {
|
|
405
|
-
|
|
417
|
+
// Issue #2189: the blocked report is printed as the run stops, so the
|
|
418
|
+
// bounded head+tail excerpt always contains it; the middle of a multi-
|
|
419
|
+
// gigabyte transcript never has to be resident to find it.
|
|
420
|
+
logText = await readLogTextBounded(logPath, { readFile, verbose });
|
|
406
421
|
} catch (readError) {
|
|
407
422
|
if (verbose) {
|
|
408
423
|
console.log(`[VERBOSE] Could not read session log ${logPath} for subscription block: ${readError?.message || readError}`);
|
|
@@ -439,6 +454,35 @@ async function getDockerContainerFilesystemSizeForSession(sessionName, sessionIn
|
|
|
439
454
|
return null;
|
|
440
455
|
}
|
|
441
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* How long a docker writable-layer measurement stays good enough while the
|
|
459
|
+
* session is still running (issue #2189).
|
|
460
|
+
*
|
|
461
|
+
* `docker ps --size` walks the container's whole writable layer. On the 27 GB
|
|
462
|
+
* layer in the captured incident that is expensive, and the monitor was paying
|
|
463
|
+
* it once per session per 30-second poll for no gain: the number is only ever
|
|
464
|
+
* *reported* at completion. The completion measurement is never throttled, so
|
|
465
|
+
* the number in the report is still fresh.
|
|
466
|
+
*/
|
|
467
|
+
export const DOCKER_FILESYSTEM_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
468
|
+
/**
|
|
469
|
+
* Whether the writable-layer size should be measured on this poll.
|
|
470
|
+
*
|
|
471
|
+
* @param {Object} sessionInfo
|
|
472
|
+
* @param {Object} [options]
|
|
473
|
+
* @param {boolean} [options.stillRunning] - The session is still executing
|
|
474
|
+
* @param {number} [options.now] - Epoch ms (injectable for tests)
|
|
475
|
+
* @param {number} [options.intervalMs]
|
|
476
|
+
* @returns {boolean}
|
|
477
|
+
*/
|
|
478
|
+
export function shouldRefreshDockerFilesystemSize(sessionInfo, { stillRunning = true, now = Date.now(), intervalMs = DOCKER_FILESYSTEM_REFRESH_INTERVAL_MS } = {}) {
|
|
479
|
+
if (sessionInfo?.isolationBackend !== 'docker') return false;
|
|
480
|
+
// The completion report quotes this number, so it is always measured fresh.
|
|
481
|
+
if (!stillRunning) return true;
|
|
482
|
+
const observedAt = Date.parse(sessionInfo?.containerFilesystemLastObservedAt || '');
|
|
483
|
+
if (!Number.isFinite(observedAt)) return true;
|
|
484
|
+
return now - observedAt >= intervalMs;
|
|
485
|
+
}
|
|
442
486
|
async function refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
|
|
443
487
|
const bytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose, sizeProvider });
|
|
444
488
|
if (!Number.isFinite(bytes)) return null;
|
|
@@ -665,6 +709,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
665
709
|
console.log(`[VERBOSE] Checking ${sessions.length} active session(s)...`);
|
|
666
710
|
}
|
|
667
711
|
for (const { sessionName, sessionInfo } of sessions) {
|
|
712
|
+
// Issue #2189: a session whose completion notification was already
|
|
713
|
+
// delivered is terminal. Without this latch the monitor re-entered the whole
|
|
714
|
+
// completion pipeline on every poll — re-resolving the linked pull request,
|
|
715
|
+
// re-scanning a 134 MB log, re-walking a 27 GB writable layer and re-sending
|
|
716
|
+
// a notification the user already had — because a late failure in that
|
|
717
|
+
// pipeline (or a bot restart) left the session tracked. Finalize it here,
|
|
718
|
+
// before any status probe, and do none of that work again.
|
|
719
|
+
if (isCompletionHandled(sessionInfo)) {
|
|
720
|
+
if (verbose) {
|
|
721
|
+
console.log(`[VERBOSE] Session ${sessionName} was already reported at ${sessionInfo.completionNotifiedAt}; finalizing without repeating the completion work (issue #2189)`);
|
|
722
|
+
}
|
|
723
|
+
completeSession(sessionName, sessionInfo.completionExitCode ?? 0, verbose, sessionInfo.completionStatus ?? null);
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
668
726
|
let stillRunning;
|
|
669
727
|
let exitCode = null;
|
|
670
728
|
let statusResult = null;
|
|
@@ -727,11 +785,13 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
727
785
|
}
|
|
728
786
|
}
|
|
729
787
|
}
|
|
730
|
-
if (sessionInfo
|
|
788
|
+
if (shouldRefreshDockerFilesystemSize(sessionInfo, { stillRunning })) {
|
|
731
789
|
observedContainerFilesystemBytes = await refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
|
|
732
790
|
verbose,
|
|
733
791
|
sizeProvider: options.dockerContainerSizeProvider,
|
|
734
792
|
});
|
|
793
|
+
} else if (sessionInfo?.isolationBackend === 'docker' && verbose) {
|
|
794
|
+
console.log(`[VERBOSE] Session ${sessionName}: reusing the writable-layer size observed at ${sessionInfo.containerFilesystemLastObservedAt} (issue #2189: not re-walking the layer every poll)`);
|
|
735
795
|
}
|
|
736
796
|
if (!stillRunning) {
|
|
737
797
|
console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
|
|
@@ -756,6 +816,10 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
756
816
|
statusResult,
|
|
757
817
|
readFile: options.readFile,
|
|
758
818
|
});
|
|
819
|
+
if (pullRequestUrl && sessionInfo.resolvedPullRequestUrl !== pullRequestUrl) {
|
|
820
|
+
sessionInfo.resolvedPullRequestUrl = pullRequestUrl;
|
|
821
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
822
|
+
}
|
|
759
823
|
} catch (lookupError) {
|
|
760
824
|
if (verbose) {
|
|
761
825
|
console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
|
|
@@ -806,8 +870,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
806
870
|
}
|
|
807
871
|
}
|
|
808
872
|
}
|
|
873
|
+
// Issue #2189: the last tool session id is read through the session
|
|
874
|
+
// record's cache, so the working-session log is scanned once per session
|
|
875
|
+
// instead of once per poll per consumer (the resume section below and the
|
|
876
|
+
// automatic recovery further down both need it, and both used to scan).
|
|
877
|
+
const resolveLastToolSessionId = logPath => {
|
|
878
|
+
const resolution = resolveCachedLastToolSessionId({ sessionInfo, logPath, verbose });
|
|
879
|
+
if (resolution.scanned) persistSessionSnapshot(sessionName, sessionInfo);
|
|
880
|
+
return resolution.id;
|
|
881
|
+
};
|
|
809
882
|
// Issue #1927: for a killed /solve, offer a command using the last tool
|
|
810
|
-
// session ID in the log.
|
|
883
|
+
// session ID in the log. Issue #2189 additionally *starts* that command
|
|
884
|
+
// by default (`--on-session-kill=resume`), bounded by
|
|
885
|
+
// `--session-kill-resume-attempts`, so the work is not left for a human
|
|
886
|
+
// to notice hours later.
|
|
811
887
|
const resumeExtraSections = [];
|
|
812
888
|
let killResumeCommand = null;
|
|
813
889
|
try {
|
|
@@ -821,7 +897,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
821
897
|
// `Session ID:` marker. Do not guess from neighboring UUID-named
|
|
822
898
|
// logs: start-command stores unrelated tasks in the same backend
|
|
823
899
|
// directory, which caused issue #2109's invalid resume id.
|
|
824
|
-
const lastSessionId =
|
|
900
|
+
const lastSessionId = resolveLastToolSessionId(logPath);
|
|
825
901
|
const resumeCommand = buildResumeCommand({ sessionInfo, lastSessionId });
|
|
826
902
|
const resumeSection = formatResumeSection({ lastSessionId, command: resumeCommand });
|
|
827
903
|
killResumeCommand = resumeCommand || null;
|
|
@@ -900,10 +976,21 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
900
976
|
runner: options.isolationRunner || null,
|
|
901
977
|
trackSession: options.trackSession || trackSession,
|
|
902
978
|
persistSnapshot: () => persistSessionSnapshot(sessionName, sessionInfo),
|
|
979
|
+
// Issue #2189: reuse the id already read above instead of scanning
|
|
980
|
+
// the same (possibly multi-gigabyte) log a second time.
|
|
981
|
+
readLastSessionId: logPath => resolveLastToolSessionId(logPath),
|
|
903
982
|
locale: sessionInfo?.locale || null,
|
|
904
983
|
verbose,
|
|
905
984
|
});
|
|
906
985
|
killRecovery = recovered.recovery;
|
|
986
|
+
if (killRecovery.resumed && killRecovery.sessionId) {
|
|
987
|
+
// Issue #2189: remember which session took over, so the durable
|
|
988
|
+
// history says what happened to this work and a restart cannot start
|
|
989
|
+
// a second recovery for the same kill.
|
|
990
|
+
sessionInfo.killRecoverySessionId = killRecovery.sessionId;
|
|
991
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
992
|
+
logEvent('session_kill_recovered', { sessionName, recoverySessionId: killRecovery.sessionId, attempt: killRecovery.attempt, maxAttempts: killRecovery.maxAttempts, policy: killRecovery.policy || null });
|
|
993
|
+
}
|
|
907
994
|
if (recovered.section) killReport.sections.push(recovered.section);
|
|
908
995
|
}
|
|
909
996
|
const message = formatSessionCompletionMessage({
|
|
@@ -952,6 +1039,16 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
952
1039
|
notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
|
|
953
1040
|
notifyMessageId = sent?.message_id || null;
|
|
954
1041
|
}
|
|
1042
|
+
// Issue #2189: the user has now been told. Latch that fact durably
|
|
1043
|
+
// BEFORE the remaining best-effort work (subscriber fan-out, container
|
|
1044
|
+
// cleanup), because any failure after this point used to send the whole
|
|
1045
|
+
// completion pipeline — and this notification — round again on the next
|
|
1046
|
+
// poll. The snapshot write means even a bot restart in this window
|
|
1047
|
+
// finalizes the session silently instead of re-notifying.
|
|
1048
|
+
if (markCompletionHandled(sessionInfo, { exitCode: finalExitCode, status: resolvedStatus })) {
|
|
1049
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
1050
|
+
logEvent('session_completion_notified', { sessionName, exitCode: finalExitCode ?? null, status: resolvedStatus || null, notifiedAt: sessionInfo.completionNotifiedAt });
|
|
1051
|
+
}
|
|
955
1052
|
// Issue #1688: forward the same completion message to every /subscribe-d user
|
|
956
1053
|
// in their private chat with the bot. Failures are logged but don't block
|
|
957
1054
|
// completion of the parent session.
|
|
@@ -1024,6 +1121,13 @@ async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, l
|
|
|
1024
1121
|
if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
|
|
1025
1122
|
return null;
|
|
1026
1123
|
}
|
|
1124
|
+
// Issue #2189: a completion that has to be retried must not re-run the linked-PR
|
|
1125
|
+
// lookup (an API round trip, then a scan of the session log). The answer cannot
|
|
1126
|
+
// change for a session that has already finished, so remember it.
|
|
1127
|
+
if (typeof sessionInfo.resolvedPullRequestUrl === 'string' && sessionInfo.resolvedPullRequestUrl) {
|
|
1128
|
+
if (verbose) console.log(`[VERBOSE] Reusing resolved pull request ${sessionInfo.resolvedPullRequestUrl} for this session (not looked up again)`);
|
|
1129
|
+
return sessionInfo.resolvedPullRequestUrl;
|
|
1130
|
+
}
|
|
1027
1131
|
if (typeof lookupLinkedPullRequest === 'function') {
|
|
1028
1132
|
const linkedPullRequestUrl = await lookupLinkedPullRequest(ctx);
|
|
1029
1133
|
if (linkedPullRequestUrl) return linkedPullRequestUrl;
|
|
@@ -1089,6 +1193,22 @@ export function startSessionMonitoring(bot, verbose = false, intervalMs = 30000,
|
|
|
1089
1193
|
* record whose startTime is after the current bot start (it cannot belong to a
|
|
1090
1194
|
* previous run), satisfying requirement #2's "started before bot start time".
|
|
1091
1195
|
*
|
|
1196
|
+
* Issue #2189 asks for the other half of that sentence — "on startup resume all
|
|
1197
|
+
* still-running / interrupted commands". Both cases are handled here plus the
|
|
1198
|
+
* first monitor tick, which runs synchronously after this function:
|
|
1199
|
+
*
|
|
1200
|
+
* - a session that is **still running** keeps running; re-registering it is
|
|
1201
|
+
* exactly what resumes it, and the bot reports it when it ends;
|
|
1202
|
+
* - a session that was **interrupted** (its backend is gone, or its log footer
|
|
1203
|
+
* records a kill) is detected as finished on that first tick and, under the
|
|
1204
|
+
* now-default `--on-session-kill=resume`, a recovery working session is
|
|
1205
|
+
* started from its last tool session id — bounded by
|
|
1206
|
+
* `--session-kill-resume-attempts`, whose counter is persisted, so a job
|
|
1207
|
+
* that dies every time cannot be relaunched once per bot restart forever;
|
|
1208
|
+
* - a session that was already **reported** before the previous process died
|
|
1209
|
+
* carries the persisted `completionNotifiedAt` latch and is finalized
|
|
1210
|
+
* silently, so a restart never re-notifies.
|
|
1211
|
+
*
|
|
1092
1212
|
* @param {object} [options]
|
|
1093
1213
|
* @param {object} [options.store] - Session store to load from (default: the store set via setSessionStore).
|
|
1094
1214
|
* @param {number} [options.botStartTime] - Epoch seconds; only sessions started strictly before this are resumed. Defaults to now.
|
|
@@ -1137,7 +1257,10 @@ export async function resumeTrackedSessions(options = {}) {
|
|
|
1137
1257
|
}
|
|
1138
1258
|
}
|
|
1139
1259
|
if (resumed.length > 0) {
|
|
1140
|
-
|
|
1260
|
+
// Issue #2189: say how many of these are already-reported leftovers, so the
|
|
1261
|
+
// startup line is not read as "N sessions are still working".
|
|
1262
|
+
const alreadyReported = resumed.filter(({ sessionInfo }) => isCompletionHandled(sessionInfo)).length;
|
|
1263
|
+
console.log(`♻️ Resumed monitoring of ${resumed.length} session(s) from durable store after restart${alreadyReported > 0 ? ` (${alreadyReported} already reported, will be finalized silently)` : ''}`);
|
|
1141
1264
|
} else if (verbose) {
|
|
1142
1265
|
console.log('[VERBOSE] resumeTrackedSessions: no eligible sessions to resume');
|
|
1143
1266
|
}
|
|
@@ -38,7 +38,21 @@ import path from 'node:path';
|
|
|
38
38
|
// `executionUuid` (#2154) is start-command's own identifier for the execution —
|
|
39
39
|
// the one `$ --list` prints. It differs from `sessionId`, so persisting it is
|
|
40
40
|
// what lets a restarted bot still correlate its sessions with the session list.
|
|
41
|
-
|
|
41
|
+
// Issue #2189 adds the fields that keep a completed session terminal and its
|
|
42
|
+
// per-poll cost constant:
|
|
43
|
+
// - `completionNotifiedAt`/`completionExitCode`/`completionStatus` latch the
|
|
44
|
+
// one delivered notification, so a restart between "notified" and
|
|
45
|
+
// "untracked" finalizes silently instead of re-running the whole completion
|
|
46
|
+
// pipeline and notifying the user again.
|
|
47
|
+
// - `lastToolSessionId` caches the marker found by scanning the working
|
|
48
|
+
// session log, so that scan is never O(log size) per poll.
|
|
49
|
+
// - `killRecoveryAttempts`/`killRecoverySessionId`/`killRecoveryOfSession`
|
|
50
|
+
// bound and record automatic recovery across restarts — without them a
|
|
51
|
+
// reliably-crashing job could restart once per bot launch forever.
|
|
52
|
+
// - `stopRequestedByUser`/`stopRequestedBy` must survive a restart too: with
|
|
53
|
+
// `--on-session-kill=resume` now the default, forgetting that an operator
|
|
54
|
+
// asked for the stop would relaunch the very work they cancelled.
|
|
55
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args', 'completionNotifiedAt', 'completionExitCode', 'completionStatus', 'lastToolSessionId', 'killRecoveryAttempts', 'killRecoverySessionId', 'killRecoveryOfSession', 'killRecoveryResumed', 'stopRequestedByUser', 'stopRequestedBy', 'onSessionKill', 'resolvedPullRequestUrl'];
|
|
42
56
|
|
|
43
57
|
/**
|
|
44
58
|
* Resolve the directory durable bot state is written to. Honors
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -159,9 +159,12 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
159
159
|
// solve honour the exact same value, so the report never differs by surface.
|
|
160
160
|
'on-session-kill': {
|
|
161
161
|
type: 'string',
|
|
162
|
-
description: 'What to do when a working session is killed (out of memory, disk full, forced kill): "
|
|
162
|
+
description: 'What to do when a working session is killed (out of memory, disk full, forced kill): "resume" (default) starts a new working session from the killed one\'s last tool session id and says so in the pull request and in Telegram, "report" only describes exactly what happened without restarting anything. Can also be set with HIVE_MIND_ON_SESSION_KILL.',
|
|
163
163
|
choices: ['report', 'resume'],
|
|
164
|
-
|
|
164
|
+
// Issue #2189: a kill that is only ever *offered* for resume is a kill
|
|
165
|
+
// nobody recovers from — the offer in the captured incident reached its
|
|
166
|
+
// operator six hours late. Bounded by --session-kill-resume-attempts.
|
|
167
|
+
default: 'resume',
|
|
165
168
|
},
|
|
166
169
|
'session-kill-resume-attempts': {
|
|
167
170
|
type: 'number',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
|
+
import v8 from 'node:v8';
|
|
3
4
|
|
|
4
5
|
export const RESOURCE_MARKER_PREFIX = '📈 [RESOURCES]';
|
|
5
6
|
|
|
@@ -10,8 +11,19 @@ export const RESOURCE_PHASE_SOLVE_EXIT = 'solve_exit';
|
|
|
10
11
|
export const RESOURCE_PHASE_RESTART_BEFORE = 'restart_before';
|
|
11
12
|
export const RESOURCE_PHASE_RESTART_AFTER = 'restart_after';
|
|
12
13
|
export const RESOURCE_PHASE_BOT_HEARTBEAT = 'bot_heartbeat';
|
|
14
|
+
// Issue #2189: the run that died of a V8 heap OOM inside the log sanitizer had
|
|
15
|
+
// its last resource sample at `after_agent` (RSS 373 MB), ten minutes before the
|
|
16
|
+
// fatal error — the whole log-upload phase was untelemetered, so the post-mortem
|
|
17
|
+
// could not tell a heap blow-up from an external kill. These phases bracket it.
|
|
18
|
+
export const RESOURCE_PHASE_LOG_UPLOAD_START = 'log_upload_start';
|
|
19
|
+
export const RESOURCE_PHASE_LOG_UPLOAD_END = 'log_upload_end';
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
// A V8 heap this close to its own limit is the shape of an imminent
|
|
22
|
+
// "FATAL ERROR: Reached heap limit" abort; surface it while the process is
|
|
23
|
+
// still alive to print it.
|
|
24
|
+
export const HEAP_PRESSURE_WARN_PERCENT = 85;
|
|
25
|
+
|
|
26
|
+
const RESOURCE_PHASES_BY_PREFERENCE = [RESOURCE_PHASE_SOLVE_EXIT, RESOURCE_PHASE_LOG_UPLOAD_END, RESOURCE_PHASE_LOG_UPLOAD_START, RESOURCE_PHASE_AFTER_AGENT, RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_AFTER_CLONE, RESOURCE_PHASE_SOLVE_START, RESOURCE_PHASE_RESTART_BEFORE];
|
|
15
27
|
|
|
16
28
|
function finiteNumber(value) {
|
|
17
29
|
return Number.isFinite(value) ? value : null;
|
|
@@ -139,7 +151,7 @@ export function formatExecutionContextForLog(context) {
|
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
export function captureResourceSnapshot(options = {}) {
|
|
142
|
-
const { phase = 'snapshot', diskPath = '/', now = () => new Date(), osImpl = os, fsImpl = fs, processImpl = process } = options;
|
|
154
|
+
const { phase = 'snapshot', diskPath = '/', now = () => new Date(), osImpl = os, fsImpl = fs, processImpl = process, v8Impl = v8 } = options;
|
|
143
155
|
|
|
144
156
|
const timestamp = (() => {
|
|
145
157
|
try {
|
|
@@ -196,11 +208,26 @@ export function captureResourceSnapshot(options = {}) {
|
|
|
196
208
|
return {
|
|
197
209
|
rssBytes: finiteNumber(usage.rss),
|
|
198
210
|
heapUsedBytes: finiteNumber(usage.heapUsed),
|
|
211
|
+
heapTotalBytes: finiteNumber(usage.heapTotal),
|
|
212
|
+
externalBytes: finiteNumber(usage.external),
|
|
199
213
|
};
|
|
200
214
|
} catch {
|
|
201
|
-
return { rssBytes: null, heapUsedBytes: null };
|
|
215
|
+
return { rssBytes: null, heapUsedBytes: null, heapTotalBytes: null, externalBytes: null };
|
|
216
|
+
}
|
|
217
|
+
})();
|
|
218
|
+
|
|
219
|
+
// Issue #2189: the heap *limit* is the number that was missing. A process can
|
|
220
|
+
// die of "JavaScript heap out of memory" with 10 GB of the machine still free,
|
|
221
|
+
// so RSS against total RAM says nothing; used heap against `heap_size_limit`
|
|
222
|
+
// says everything.
|
|
223
|
+
const heapLimitBytes = (() => {
|
|
224
|
+
try {
|
|
225
|
+
return finiteNumber(v8Impl.getHeapStatistics().heap_size_limit);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
202
228
|
}
|
|
203
229
|
})();
|
|
230
|
+
const heapUsedPercent = Number.isFinite(processMemory.heapUsedBytes) && Number.isFinite(heapLimitBytes) && heapLimitBytes > 0 ? clampPercent((processMemory.heapUsedBytes / heapLimitBytes) * 100) : null;
|
|
204
231
|
|
|
205
232
|
const disk = (() => {
|
|
206
233
|
const path = String(diskPath || '/');
|
|
@@ -243,6 +270,10 @@ export function captureResourceSnapshot(options = {}) {
|
|
|
243
270
|
usedBytes: usedMemoryBytes,
|
|
244
271
|
processRssBytes: processMemory.rssBytes,
|
|
245
272
|
processHeapUsedBytes: processMemory.heapUsedBytes,
|
|
273
|
+
processHeapTotalBytes: processMemory.heapTotalBytes,
|
|
274
|
+
processExternalBytes: processMemory.externalBytes,
|
|
275
|
+
processHeapLimitBytes: heapLimitBytes,
|
|
276
|
+
processHeapUsedPercent: heapUsedPercent,
|
|
246
277
|
},
|
|
247
278
|
disk,
|
|
248
279
|
};
|
|
@@ -270,6 +301,27 @@ function numberField(name, value) {
|
|
|
270
301
|
return Number.isFinite(value) ? `${name}=${value}` : `${name}=null`;
|
|
271
302
|
}
|
|
272
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Human-readable "used heap of the heap limit" summary. Issue #2189: this is the
|
|
306
|
+
* single line that would have made the incident self-diagnosing.
|
|
307
|
+
*/
|
|
308
|
+
export function formatHeapUsage(memory) {
|
|
309
|
+
const m = memory || {};
|
|
310
|
+
if (!Number.isFinite(m.processHeapUsedBytes)) return 'unknown';
|
|
311
|
+
const limit = Number.isFinite(m.processHeapLimitBytes) ? ` of ${formatBytes(m.processHeapLimitBytes)} limit` : '';
|
|
312
|
+
const percent = Number.isFinite(m.processHeapUsedPercent) ? ` (${m.processHeapUsedPercent.toFixed(1)}%)` : '';
|
|
313
|
+
return `${formatBytes(m.processHeapUsedBytes)} used${limit}${percent}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* True when the V8 heap is close enough to its own limit that the next big
|
|
318
|
+
* allocation can abort the process (issue #2189).
|
|
319
|
+
*/
|
|
320
|
+
export function isHeapUnderPressure(memory, warnPercent = HEAP_PRESSURE_WARN_PERCENT) {
|
|
321
|
+
const percent = memory?.processHeapUsedPercent;
|
|
322
|
+
return Number.isFinite(percent) && percent >= warnPercent;
|
|
323
|
+
}
|
|
324
|
+
|
|
273
325
|
export function buildResourceMarker(snapshot) {
|
|
274
326
|
const s = snapshot || {};
|
|
275
327
|
const cpu = s.cpu || {};
|
|
@@ -287,6 +339,11 @@ export function buildResourceMarker(snapshot) {
|
|
|
287
339
|
numberField('memAvailableBytes', memory.availableBytes),
|
|
288
340
|
numberField('memUsedBytes', memory.usedBytes),
|
|
289
341
|
numberField('processRssBytes', memory.processRssBytes),
|
|
342
|
+
numberField('processHeapUsedBytes', memory.processHeapUsedBytes),
|
|
343
|
+
numberField('processHeapTotalBytes', memory.processHeapTotalBytes),
|
|
344
|
+
numberField('processExternalBytes', memory.processExternalBytes),
|
|
345
|
+
numberField('processHeapLimitBytes', memory.processHeapLimitBytes),
|
|
346
|
+
numberField('processHeapUsedPercent', memory.processHeapUsedPercent),
|
|
290
347
|
`diskPath=${encodeValue(disk.path || '/')}`,
|
|
291
348
|
numberField('diskTotalBytes', disk.totalBytes),
|
|
292
349
|
numberField('diskAvailableBytes', disk.availableBytes),
|
|
@@ -294,6 +351,7 @@ export function buildResourceMarker(snapshot) {
|
|
|
294
351
|
numberField('diskUsedPercent', disk.usedPercent),
|
|
295
352
|
disk.error ? `error=${encodeValue(disk.error)}` : null,
|
|
296
353
|
`mem=${encodeValue(`${formatBytes(memory.availableBytes)} available / ${formatBytes(memory.totalBytes)} total`)}`,
|
|
354
|
+
`heap=${encodeValue(formatHeapUsage(memory))}`,
|
|
297
355
|
`disk=${encodeValue(`${formatBytes(disk.availableBytes)} available / ${formatBytes(disk.totalBytes)} total`)}`,
|
|
298
356
|
]
|
|
299
357
|
.filter(Boolean)
|
|
@@ -332,6 +390,11 @@ function parseMarkerLine(line) {
|
|
|
332
390
|
availableBytes: parseNumber(fields.memAvailableBytes),
|
|
333
391
|
usedBytes: parseNumber(fields.memUsedBytes),
|
|
334
392
|
processRssBytes: parseNumber(fields.processRssBytes),
|
|
393
|
+
processHeapUsedBytes: parseNumber(fields.processHeapUsedBytes),
|
|
394
|
+
processHeapTotalBytes: parseNumber(fields.processHeapTotalBytes),
|
|
395
|
+
processExternalBytes: parseNumber(fields.processExternalBytes),
|
|
396
|
+
processHeapLimitBytes: parseNumber(fields.processHeapLimitBytes),
|
|
397
|
+
processHeapUsedPercent: parseNumber(fields.processHeapUsedPercent),
|
|
335
398
|
},
|
|
336
399
|
disk: {
|
|
337
400
|
path: decodeURIComponent(fields.diskPath || '/'),
|
|
@@ -376,7 +439,8 @@ export function formatResourceSnapshotForLog(snapshot, label = null) {
|
|
|
376
439
|
const cpu = s.cpu || {};
|
|
377
440
|
const memory = s.memory || {};
|
|
378
441
|
const disk = s.disk || {};
|
|
379
|
-
const lines = [`📈 Resource usage (${phaseLabel}):`, ` CPU load: ${formatNumber(cpu.load1)} ${formatNumber(cpu.load5)} ${formatNumber(cpu.load15)}${Number.isFinite(cpu.cpuCount) ? ` (${cpu.cpuCount} CPUs)` : ''}`, ` Memory: ${formatBytes(memory.availableBytes)} available / ${formatBytes(memory.totalBytes)} total (${formatBytes(memory.usedBytes)} used)`, ` Process RSS: ${formatBytes(memory.processRssBytes)}
|
|
442
|
+
const lines = [`📈 Resource usage (${phaseLabel}):`, ` CPU load: ${formatNumber(cpu.load1)} ${formatNumber(cpu.load5)} ${formatNumber(cpu.load15)}${Number.isFinite(cpu.cpuCount) ? ` (${cpu.cpuCount} CPUs)` : ''}`, ` Memory: ${formatBytes(memory.availableBytes)} available / ${formatBytes(memory.totalBytes)} total (${formatBytes(memory.usedBytes)} used)`, ` Process RSS: ${formatBytes(memory.processRssBytes)}, V8 heap: ${formatHeapUsage(memory)}`, ` Disk (${disk.path || '/'}): ${formatBytes(disk.availableBytes)} available / ${formatBytes(disk.totalBytes)} total${Number.isFinite(disk.usedPercent) ? ` (${disk.usedPercent.toFixed(1)}% used)` : ''}`];
|
|
443
|
+
if (isHeapUnderPressure(memory)) lines.push(` ⚠️ V8 heap is at ${memory.processHeapUsedPercent.toFixed(1)}% of its limit — a further allocation can abort the process with "JavaScript heap out of memory"`);
|
|
380
444
|
if (disk.error) lines.push(` Disk probe error: ${disk.error}`);
|
|
381
445
|
lines.push(buildResourceMarker(snapshot));
|
|
382
446
|
return lines.join('\n');
|
|
@@ -422,6 +486,9 @@ export function summarizeResourceSnapshot(snapshot) {
|
|
|
422
486
|
availableBytes: memory.availableBytes,
|
|
423
487
|
usedBytes: memory.usedBytes,
|
|
424
488
|
processRssBytes: memory.processRssBytes,
|
|
489
|
+
processHeapUsedBytes: memory.processHeapUsedBytes,
|
|
490
|
+
processHeapLimitBytes: memory.processHeapLimitBytes,
|
|
491
|
+
processHeapUsedPercent: memory.processHeapUsedPercent,
|
|
425
492
|
},
|
|
426
493
|
disk: {
|
|
427
494
|
path: disk.path,
|
|
@@ -24,7 +24,8 @@ import path from 'path';
|
|
|
24
24
|
import os from 'os';
|
|
25
25
|
import fs from 'fs/promises';
|
|
26
26
|
import { constants as fsConstants } from 'fs';
|
|
27
|
-
import { sanitizeForPublication
|
|
27
|
+
import { sanitizeForPublication } from './token-sanitization.lib.mjs';
|
|
28
|
+
import { sanitizeLogFileToFileBounded } from './log-sanitize-worker.lib.mjs';
|
|
28
29
|
import { safeReply, safeReplyWithDocument, safeSendDocument } from './telegram-safe-reply.lib.mjs';
|
|
29
30
|
|
|
30
31
|
const UUID_RE = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i;
|
|
@@ -43,8 +44,11 @@ async function prepareSanitizedLogUpload(logPath, caption) {
|
|
|
43
44
|
const sanitizedPath = path.join(tempDir, path.basename(logPath));
|
|
44
45
|
|
|
45
46
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
// Issue #2189: `/log` accepts files up to Telegram's 50 MB document limit,
|
|
48
|
+
// and this used to hold the log twice (raw string + sanitized string) plus a
|
|
49
|
+
// third copy inside the sanitizer. The streaming sanitizer writes the same
|
|
50
|
+
// artifact one block at a time, so a 50 MB log costs the same as a 50 kB one.
|
|
51
|
+
const [, safeCaption] = await Promise.all([sanitizeLogFileToFileBounded({ sourcePath: logPath, destPath: sanitizedPath }), sanitizeForPublication(caption)]);
|
|
48
52
|
return {
|
|
49
53
|
path: sanitizedPath,
|
|
50
54
|
caption: safeCaption,
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import { extractSessionIdFromText, decideLogDestination, resolveLogPath } from './telegram-log-command.lib.mjs';
|
|
10
|
+
import { readLogTailText } from './log-bounded-read.lib.mjs';
|
|
10
11
|
import { parseSessionExitFooter } from './isolation-runner.lib.mjs';
|
|
11
12
|
import { safeReply, safeSendMessage, safeEditMessageText } from './telegram-safe-reply.lib.mjs';
|
|
12
13
|
import { classifyExitStatus, isFailureSessionStatus } from './session-status.lib.mjs';
|
|
@@ -15,6 +16,11 @@ const DEFAULT_WIDTH = 120;
|
|
|
15
16
|
const DEFAULT_HEIGHT = 25;
|
|
16
17
|
const DEFAULT_INTERVAL_MS = 2500;
|
|
17
18
|
const DEFAULT_MAX_CHARS = 3400;
|
|
19
|
+
// Issue #2189: the watch loop re-reads the log every `intervalMs`, but it only
|
|
20
|
+
// ever renders the last `height` lines and looks for the exit footer, both of
|
|
21
|
+
// which live at the very end. Reading the whole file made a poll tick cost
|
|
22
|
+
// O(log size) — a 134 MB session log allocated 134 MB of string per tick.
|
|
23
|
+
const TERMINAL_WATCH_TAIL_BYTES = 256 * 1024;
|
|
18
24
|
const GITHUB_URL_RE = /https:\/\/github\.com\/[^\s"'`<>]+/i;
|
|
19
25
|
const activeWatches = new Map();
|
|
20
26
|
|
|
@@ -141,7 +147,9 @@ export function formatTerminalWatchMessage({ sessionId, statusResult = null, log
|
|
|
141
147
|
|
|
142
148
|
async function readLogFile(logPath) {
|
|
143
149
|
try {
|
|
144
|
-
|
|
150
|
+
const { size } = await fs.stat(logPath);
|
|
151
|
+
if (!size) return '';
|
|
152
|
+
return await readLogTailText(logPath, { maxBytes: TERMINAL_WATCH_TAIL_BYTES });
|
|
145
153
|
} catch (error) {
|
|
146
154
|
if (error?.code === 'ENOENT') return '';
|
|
147
155
|
throw error;
|