@link-assistant/hive-mind 2.0.28 → 2.1.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 +13 -0
- package/package.json +1 -1
- package/src/bot-lifecycle.lib.mjs +17 -1
- package/src/config.lib.mjs +26 -10
- package/src/models/index.mjs +10 -4
- package/src/session-monitor.lib.mjs +33 -12
- package/src/session-store.lib.mjs +1 -1
- package/src/solve.config.lib.mjs +1 -1
- package/src/solve.escalate.lib.mjs +2 -0
- package/src/solve.mjs +14 -3
- package/src/solve.resource-diagnostics.lib.mjs +435 -0
- package/src/solve.restart-shared.lib.mjs +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4e21d2a: Fix Docker task disk-usage reporting so Telegram completion messages use task container writable-layer samples instead of filesystem-capacity resource markers from the parent deployment.
|
|
8
|
+
- fdbf448: Add full support for Claude Sonnet 5 (`claude-sonnet-5`) and make it the default model for `--tool claude`. The bare `sonnet` alias now resolves to `claude-sonnet-5` (previously `claude-sonnet-4-6`). Sonnet 5 supports 1M context (`[1m]`), the full effort ladder including `xhigh` and `max`, 128K max output tokens, and adaptive-thinking-only environment handling. The `sonnet-4-6`/`claude-sonnet-4-6` aliases are retained for backward compatibility. (Issue #2003)
|
|
9
|
+
|
|
10
|
+
## 2.0.29
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 0cafc64: Add solve resource diagnostics and Docker disk-usage fallback markers so Telegram completion messages can show full container filesystem usage even when the task container cannot be inspected after exit.
|
|
15
|
+
|
|
3
16
|
## 2.0.28
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* @see https://github.com/link-assistant/hive-mind/issues/1927
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { RESOURCE_PHASE_BOT_HEARTBEAT, captureResourceSnapshot, summarizeResourceSnapshot } from './solve.resource-diagnostics.lib.mjs';
|
|
16
|
+
|
|
15
17
|
const DEFAULT_HEARTBEAT_INTERVAL_MS = 60 * 1000;
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -26,14 +28,28 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 60 * 1000;
|
|
|
26
28
|
*
|
|
27
29
|
* @returns {{ start: () => void, stop: () => void, beat: () => void, get timer(): any }}
|
|
28
30
|
*/
|
|
29
|
-
export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, processImpl = process, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
|
|
31
|
+
export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS, processImpl = process, setIntervalImpl = setInterval, clearIntervalImpl = clearInterval, captureResources = captureResourceSnapshot, resourceDiskPath = '/' } = {}) {
|
|
30
32
|
let timer = null;
|
|
31
33
|
|
|
32
34
|
const beat = () => {
|
|
33
35
|
try {
|
|
36
|
+
let resources = null;
|
|
37
|
+
try {
|
|
38
|
+
if (typeof captureResources === 'function') {
|
|
39
|
+
resources = summarizeResourceSnapshot(
|
|
40
|
+
captureResources({
|
|
41
|
+
phase: RESOURCE_PHASE_BOT_HEARTBEAT,
|
|
42
|
+
diskPath: resourceDiskPath,
|
|
43
|
+
})
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
resources = null;
|
|
48
|
+
}
|
|
34
49
|
logger.heartbeat({
|
|
35
50
|
activeSessions: typeof getActiveSessionCount === 'function' ? getActiveSessionCount(false) : undefined,
|
|
36
51
|
uptimeSec: Math.floor(processImpl.uptime()),
|
|
52
|
+
resources,
|
|
37
53
|
});
|
|
38
54
|
} catch {
|
|
39
55
|
/* heartbeat must never crash the bot */
|
package/src/config.lib.mjs
CHANGED
|
@@ -266,6 +266,22 @@ const isSonnet46OrLater = model => {
|
|
|
266
266
|
return m === 'sonnet' || m === 'sonnet-4-6' || m.includes('sonnet-4-6') || m.includes('sonnet-5');
|
|
267
267
|
};
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Check if a model is Claude Sonnet 5 (Issue #2003)
|
|
271
|
+
* Sonnet 5 (`claude-sonnet-5`) is the current default for `--tool claude` (the bare
|
|
272
|
+
* `sonnet` alias now resolves to it). Unlike Sonnet 4.6 it supports the `xhigh` effort
|
|
273
|
+
* level, up to 128k output tokens, and uses adaptive thinking only (extended/manual
|
|
274
|
+
* thinking with an explicit budget is unavailable), matching the Fable/Mythos 5 API
|
|
275
|
+
* constraints. See: https://www.anthropic.com/news/claude-sonnet-5
|
|
276
|
+
* @param {string} model - The model name or ID
|
|
277
|
+
* @returns {boolean} True if the model is Claude Sonnet 5
|
|
278
|
+
*/
|
|
279
|
+
export const isSonnet5 = model => {
|
|
280
|
+
if (!model) return false;
|
|
281
|
+
const m = model.toLowerCase();
|
|
282
|
+
return m === 'sonnet' || m === 'sonnet-5' || m.includes('sonnet-5');
|
|
283
|
+
};
|
|
284
|
+
|
|
269
285
|
const isMythosPreview = model => {
|
|
270
286
|
if (!model) return false;
|
|
271
287
|
return model.toLowerCase().includes('mythos');
|
|
@@ -328,11 +344,11 @@ export const supportsEffortLevel = model => {
|
|
|
328
344
|
/**
|
|
329
345
|
* Check if a model supports the xhigh effort level.
|
|
330
346
|
* Official docs list xhigh for Claude Fable 5, Claude Mythos 5, Claude Opus 4.7,
|
|
331
|
-
*
|
|
347
|
+
* Opus 4.8, and Sonnet 5 (Issue #1832, Issue #1875, Issue #2003).
|
|
332
348
|
* @param {string} model - The model name or ID
|
|
333
349
|
* @returns {boolean} True if the model supports xhigh effort
|
|
334
350
|
*/
|
|
335
|
-
export const supportsXHighEffortLevel = model => isFable5OrMythos5(model) || isOpus47(model);
|
|
351
|
+
export const supportsXHighEffortLevel = model => isFable5OrMythos5(model) || isOpus47(model) || isSonnet5(model);
|
|
336
352
|
|
|
337
353
|
/**
|
|
338
354
|
* Check if a model supports the max effort level.
|
|
@@ -344,14 +360,14 @@ export const supportsXHighEffortLevel = model => isFable5OrMythos5(model) || isO
|
|
|
344
360
|
export const supportsMaxEffortLevel = model => isFable5OrMythos5(model) || isMythosPreview(model) || isOpus47OrLater(model) || isOpus46(model) || isSonnet46OrLater(model);
|
|
345
361
|
|
|
346
362
|
/**
|
|
347
|
-
* Get the max output tokens for a specific model (Issue #1221, Issue #1875)
|
|
348
|
-
* Claude Fable 5 and Claude
|
|
349
|
-
* the Opus 4.6+ ceiling.
|
|
363
|
+
* Get the max output tokens for a specific model (Issue #1221, Issue #1875, Issue #2003)
|
|
364
|
+
* Claude Fable 5, Claude Mythos 5, and Claude Sonnet 5 support up to 128k output tokens,
|
|
365
|
+
* matching the Opus 4.6+ ceiling.
|
|
350
366
|
* @param {string} model - The model name or ID
|
|
351
367
|
* @returns {number} The max output tokens for the model
|
|
352
368
|
*/
|
|
353
369
|
export const getMaxOutputTokensForModel = model => {
|
|
354
|
-
if (isOpus46OrLater(model) || isFable5OrMythos5(model)) {
|
|
370
|
+
if (isOpus46OrLater(model) || isFable5OrMythos5(model) || isSonnet5(model)) {
|
|
355
371
|
return claudeCode.maxOutputTokensOpus46;
|
|
356
372
|
}
|
|
357
373
|
return claudeCode.maxOutputTokens;
|
|
@@ -564,12 +580,12 @@ export const getClaudeEnv = (options = {}) => {
|
|
|
564
580
|
|
|
565
581
|
// Opus 4.7+ always uses adaptive thinking — MAX_THINKING_TOKENS has no effect (Issue #1620, Issue #1832)
|
|
566
582
|
// Opus 4.8 inherits this constraint: adaptive thinking is the only thinking mode.
|
|
567
|
-
// Claude Fable 5 and Claude
|
|
568
|
-
// thinking is unavailable and `thinking: {type: "disabled"}` is rejected,
|
|
569
|
-
// MAX_THINKING_TOKENS=0 would be invalid for them (Issue #1875).
|
|
583
|
+
// Claude Fable 5, Claude Mythos 5, and Claude Sonnet 5 are adaptive-thinking-only too:
|
|
584
|
+
// extended/manual thinking is unavailable and `thinking: {type: "disabled"}` is rejected,
|
|
585
|
+
// so a MAX_THINKING_TOKENS=0 would be invalid for them (Issue #1875, Issue #2003).
|
|
570
586
|
// For Opus 4.6 and earlier, MAX_THINKING_TOKENS controls extended thinking (Claude Code >= 2.1.12)
|
|
571
587
|
// Default is 0 (thinking disabled) per Issue #1238.
|
|
572
|
-
const adaptiveThinkingOnly = options.model && (isOpus47OrLater(options.model) || isFable5OrMythos5(options.model));
|
|
588
|
+
const adaptiveThinkingOnly = options.model && (isOpus47OrLater(options.model) || isFable5OrMythos5(options.model) || isSonnet5(options.model));
|
|
573
589
|
if (adaptiveThinkingOnly) {
|
|
574
590
|
// Remove any inherited MAX_THINKING_TOKENS from process.env — these models ignore it
|
|
575
591
|
delete env.MAX_THINKING_TOKENS;
|
package/src/models/index.mjs
CHANGED
|
@@ -29,10 +29,10 @@ const execFileAsync = promisify(execFile);
|
|
|
29
29
|
// ─── MODEL DATA ──────────────────────────────────────────────────────────────
|
|
30
30
|
|
|
31
31
|
// Claude models (Anthropic API)
|
|
32
|
-
// Updated for Opus 4.5/4.6/4.7/4.8, Sonnet 4.6, and Fable 5 / Mythos 5 support
|
|
33
|
-
// (Issue #1221, Issue #1238, Issue #1329, Issue #1433, Issue #1620, Issue #1832, Issue #1875)
|
|
32
|
+
// Updated for Opus 4.5/4.6/4.7/4.8, Sonnet 4.6/5, and Fable 5 / Mythos 5 support
|
|
33
|
+
// (Issue #1221, Issue #1238, Issue #1329, Issue #1433, Issue #1620, Issue #1832, Issue #1875, Issue #2003)
|
|
34
34
|
export const claudeModels = {
|
|
35
|
-
sonnet: 'claude-sonnet-
|
|
35
|
+
sonnet: 'claude-sonnet-5', // Sonnet 5 (default, Issue #2003)
|
|
36
36
|
opus: 'claude-opus-4-8', // Opus 4.8 (Issue #1832)
|
|
37
37
|
haiku: 'claude-haiku-4-5-20251001', // Haiku 4.5
|
|
38
38
|
'haiku-3-5': 'claude-3-5-haiku-20241022', // Haiku 3.5
|
|
@@ -46,6 +46,7 @@ export const claudeModels = {
|
|
|
46
46
|
'mythos-5': 'claude-mythos-5', // Mythos 5 short alias
|
|
47
47
|
'claude-mythos-5': 'claude-mythos-5', // Mythos 5 full ID
|
|
48
48
|
// Shorter version aliases (Issue #1221, Issue #1329 - PR comment feedback)
|
|
49
|
+
'sonnet-5': 'claude-sonnet-5', // Sonnet 5 short alias (Issue #2003)
|
|
49
50
|
'sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 short alias (Issue #1329)
|
|
50
51
|
'opus-4-8': 'claude-opus-4-8', // Opus 4.8 short alias (Issue #1832)
|
|
51
52
|
'opus-4-7': 'claude-opus-4-7', // Opus 4.7 short alias (backward compatibility)
|
|
@@ -56,6 +57,7 @@ export const claudeModels = {
|
|
|
56
57
|
// Version aliases for backward compatibility (Issue #1221, Issue #1329, Issue #1620, Issue #1832)
|
|
57
58
|
'claude-opus-4-8': 'claude-opus-4-8', // Opus 4.8 (Issue #1832)
|
|
58
59
|
'claude-opus-4-7': 'claude-opus-4-7', // Opus 4.7 (backward compatibility)
|
|
60
|
+
'claude-sonnet-5': 'claude-sonnet-5', // Sonnet 5 (Issue #2003)
|
|
59
61
|
'claude-sonnet-4-6': 'claude-sonnet-4-6', // Sonnet 4.6 (Issue #1329)
|
|
60
62
|
'claude-opus-4-6': 'claude-opus-4-6', // Opus 4.6 (backward compatibility)
|
|
61
63
|
'claude-opus-4-5': 'claude-opus-4-5-20251101', // Opus 4.5
|
|
@@ -207,10 +209,12 @@ export const MODELS_SUPPORTING_1M_CONTEXT = [
|
|
|
207
209
|
'claude-opus-4-7', // Opus 4.7 (Issue #1620)
|
|
208
210
|
'claude-opus-4-6',
|
|
209
211
|
'claude-opus-4-5-20251101',
|
|
212
|
+
'claude-sonnet-5', // Sonnet 5 — 1M context (Issue #2003)
|
|
210
213
|
'claude-sonnet-4-6', // Sonnet 4.6 (Issue #1329)
|
|
211
214
|
'claude-sonnet-4-5-20250929',
|
|
212
215
|
'claude-sonnet-4-5',
|
|
213
|
-
'sonnet', // Now maps to Sonnet
|
|
216
|
+
'sonnet', // Now maps to Sonnet 5 (Issue #2003)
|
|
217
|
+
'sonnet-5', // Short alias (Issue #2003)
|
|
214
218
|
'sonnet-4-6', // Short alias (Issue #1329)
|
|
215
219
|
'opus', // Now maps to Opus 4.8 (Issue #1832)
|
|
216
220
|
'opus-4-8', // Short alias (Issue #1832)
|
|
@@ -1148,6 +1152,8 @@ export const defaultFallbackModels = {
|
|
|
1148
1152
|
'claude-mythos-5': 'fable',
|
|
1149
1153
|
'claude-opus-4-8': 'opus-4-7',
|
|
1150
1154
|
'claude-opus-4-7': 'opus-4-6',
|
|
1155
|
+
// Claude Sonnet 5 falls back to the prior Sonnet generation (Issue #2003).
|
|
1156
|
+
'claude-sonnet-5': 'sonnet-4-6',
|
|
1151
1157
|
},
|
|
1152
1158
|
codex: {
|
|
1153
1159
|
'gpt-5.6-sol': 'gpt-5.5',
|
|
@@ -193,6 +193,15 @@ function isPersistableSession(sessionInfo) {
|
|
|
193
193
|
return Boolean(sessionInfo?.isolationBackend && sessionInfo?.sessionId);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
function persistSessionSnapshot(sessionName, sessionInfo) {
|
|
197
|
+
if (!sessionStore || !isPersistableSession(sessionInfo)) return;
|
|
198
|
+
try {
|
|
199
|
+
sessionStore.persist(sessionName, sessionInfo);
|
|
200
|
+
} catch {
|
|
201
|
+
/* best effort — persistence must never break monitoring */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
196
205
|
/**
|
|
197
206
|
* Look up the in-memory record for a session id (UUID for isolation sessions
|
|
198
207
|
* or the screen session name for non-isolation sessions). Returns null when no
|
|
@@ -343,7 +352,7 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
|
|
|
343
352
|
* build the Telegram extraSection. Returns an empty string if there is no
|
|
344
353
|
* repository or docker filesystem data to show.
|
|
345
354
|
*/
|
|
346
|
-
async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile, isolationBackend = null, containerFilesystemStartBytes = null, containerFilesystemAfterBytes = null } = {}) {
|
|
355
|
+
export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile, isolationBackend = null, containerFilesystemStartBytes = null, containerFilesystemAfterBytes = null } = {}) {
|
|
347
356
|
if (!logPath && !Number.isFinite(containerFilesystemStartBytes) && !Number.isFinite(containerFilesystemAfterBytes)) return '';
|
|
348
357
|
try {
|
|
349
358
|
const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
|
|
@@ -392,6 +401,19 @@ async function getDockerContainerFilesystemSizeForSession(sessionName, sessionIn
|
|
|
392
401
|
}
|
|
393
402
|
}
|
|
394
403
|
|
|
404
|
+
async function refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
|
|
405
|
+
const bytes = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose, sizeProvider });
|
|
406
|
+
if (!Number.isFinite(bytes)) return null;
|
|
407
|
+
sessionInfo.containerFilesystemLastBytes = bytes;
|
|
408
|
+
sessionInfo.containerFilesystemLastObservedAt = new Date().toISOString();
|
|
409
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
410
|
+
return bytes;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function getLastKnownDockerContainerFilesystemSize(sessionInfo) {
|
|
414
|
+
return Number.isFinite(sessionInfo?.containerFilesystemLastBytes) ? sessionInfo.containerFilesystemLastBytes : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
395
417
|
function isSuccessfulTaskCompletion({ exitCode = null, status = null } = {}) {
|
|
396
418
|
const outcome = classifySessionOutcome({ exitCode, status });
|
|
397
419
|
if (outcome.failed) return false;
|
|
@@ -657,6 +679,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
657
679
|
let exitCode = null;
|
|
658
680
|
let statusResult = null;
|
|
659
681
|
let resolvedStatus = null;
|
|
682
|
+
let observedContainerFilesystemBytes = null;
|
|
660
683
|
|
|
661
684
|
if (sessionInfo.isolationBackend && sessionInfo.sessionId) {
|
|
662
685
|
// Isolation mode: use $ --status, with screen -ls only as a fallback
|
|
@@ -682,13 +705,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
682
705
|
// the log footer to learn whether it was killed.
|
|
683
706
|
if (statusResult?.logPath && sessionInfo.logPath !== statusResult.logPath) {
|
|
684
707
|
sessionInfo.logPath = statusResult.logPath;
|
|
685
|
-
|
|
686
|
-
try {
|
|
687
|
-
sessionStore.persist(sessionName, sessionInfo);
|
|
688
|
-
} catch {
|
|
689
|
-
/* best effort — persistence must never break monitoring */
|
|
690
|
-
}
|
|
691
|
-
}
|
|
708
|
+
persistSessionSnapshot(sessionName, sessionInfo);
|
|
692
709
|
}
|
|
693
710
|
} else {
|
|
694
711
|
// Issue #1586: Non-isolation screen sessions cannot reliably detect
|
|
@@ -710,6 +727,13 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
710
727
|
}
|
|
711
728
|
}
|
|
712
729
|
|
|
730
|
+
if (sessionInfo?.isolationBackend === 'docker') {
|
|
731
|
+
observedContainerFilesystemBytes = await refreshDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
|
|
732
|
+
verbose,
|
|
733
|
+
sizeProvider: options.dockerContainerSizeProvider,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
713
737
|
if (!stillRunning) {
|
|
714
738
|
console.log(`Session ${sessionName} has finished. Sending notification to chat ${sessionInfo.chatId}`);
|
|
715
739
|
|
|
@@ -821,10 +845,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
821
845
|
const diskExtraSections = [];
|
|
822
846
|
try {
|
|
823
847
|
const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
|
|
824
|
-
const containerFilesystemAfterBytes =
|
|
825
|
-
verbose,
|
|
826
|
-
sizeProvider: options.dockerContainerSizeProvider,
|
|
827
|
-
});
|
|
848
|
+
const containerFilesystemAfterBytes = Number.isFinite(observedContainerFilesystemBytes) ? observedContainerFilesystemBytes : getLastKnownDockerContainerFilesystemSize(sessionInfo);
|
|
828
849
|
const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, {
|
|
829
850
|
verbose,
|
|
830
851
|
readFile: options.readFile,
|
|
@@ -33,7 +33,7 @@ import path from 'node:path';
|
|
|
33
33
|
// excluded so the snapshot stays small and safe to reload.
|
|
34
34
|
// `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
|
|
35
35
|
// with its exact original invocation plus `--resume <lastSessionId>`.
|
|
36
|
-
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
36
|
+
const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Resolve the directory durable bot state is written to. Honors
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -299,7 +299,7 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
299
299
|
},
|
|
300
300
|
think: {
|
|
301
301
|
type: 'string',
|
|
302
|
-
description: 'Thinking level hint. For Claude, translated to --thinking-budget for Claude Code >= 2.1.12 (off=0, low=~8000, medium=~16000, high=~24000, xhigh/max=31999) and to CLAUDE_CODE_EFFORT_LEVEL when supported. Fable 5/Mythos 5/Opus 4.8/4.7 support xhigh and max; Opus 4.6/Sonnet 4.6/Mythos Preview support max; Opus 4.5 uses high for xhigh/max. For Codex, mapped to reasoning effort (off=none, low=low, medium=medium, high=high, xhigh/max=xhigh).',
|
|
302
|
+
description: 'Thinking level hint. For Claude, translated to --thinking-budget for Claude Code >= 2.1.12 (off=0, low=~8000, medium=~16000, high=~24000, xhigh/max=31999) and to CLAUDE_CODE_EFFORT_LEVEL when supported. Fable 5/Mythos 5/Sonnet 5/Opus 4.8/4.7 support xhigh and max; Opus 4.6/Sonnet 4.6/Mythos Preview support max; Opus 4.5 uses high for xhigh/max. For Codex, mapped to reasoning effort (off=none, low=low, medium=medium, high=high, xhigh/max=xhigh).',
|
|
303
303
|
choices: ['off', 'low', 'medium', 'high', 'xhigh', 'max'],
|
|
304
304
|
default: undefined,
|
|
305
305
|
},
|
|
@@ -72,8 +72,10 @@ const TIER_ALIASES = {
|
|
|
72
72
|
'claude-haiku-4-5': 'haiku',
|
|
73
73
|
'claude-haiku-4-5-20251001': 'haiku',
|
|
74
74
|
sonnet: 'sonnet',
|
|
75
|
+
'sonnet-5': 'sonnet',
|
|
75
76
|
'sonnet-4-6': 'sonnet',
|
|
76
77
|
'sonnet-4-5': 'sonnet',
|
|
78
|
+
'claude-sonnet-5': 'sonnet',
|
|
77
79
|
'claude-sonnet-4-6': 'sonnet',
|
|
78
80
|
'claude-sonnet-4-5': 'sonnet',
|
|
79
81
|
opus: 'opus',
|
package/src/solve.mjs
CHANGED
|
@@ -49,7 +49,8 @@ const { runKeepWorkingUntilDone } = await import('./solve.keep-working.lib.mjs')
|
|
|
49
49
|
const { runEscalation } = await import('./solve.escalate.lib.mjs');
|
|
50
50
|
const { finalizeSolveProcess } = await import('./solve.finalize.lib.mjs');
|
|
51
51
|
const exitHandler = await import('./exit-handler.lib.mjs');
|
|
52
|
-
const { initializeExitHandler, installGlobalExitHandlers, safeExit, logActiveHandles } = exitHandler;
|
|
52
|
+
const { initializeExitHandler, installGlobalExitHandlers, safeExit: baseSafeExit, logActiveHandles } = exitHandler;
|
|
53
|
+
const { RESOURCE_PHASE_AFTER_AGENT, RESOURCE_PHASE_AFTER_CLONE, RESOURCE_PHASE_SOLVE_EXIT, RESOURCE_PHASE_SOLVE_START, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
|
|
53
54
|
const { createInterruptWrapper } = await import('./solve.interrupt.lib.mjs');
|
|
54
55
|
// Issue #1823: working-session guard for --do-not-shutdown-in-the-middle-of-working-session.
|
|
55
56
|
const { configureWorkingSession, beginWorkingSession, endWorkingSession } = await import('./working-session.lib.mjs');
|
|
@@ -69,9 +70,7 @@ const { prepareFeedbackAndTimestamps, checkUncommittedChanges, checkForkActions
|
|
|
69
70
|
const { validateAndExitOnInvalidClaudeSubAgentModel, validateAndExitOnInvalidModel } = await import('./models/index.mjs');
|
|
70
71
|
const { autoAcceptInviteForRepo } = await import('./solve.accept-invite.lib.mjs');
|
|
71
72
|
const { handleAutoForkOption, handleMaintainerForkAccess } = await import('./solve.fork-detection.lib.mjs');
|
|
72
|
-
// Initialize log file early (before argument parsing) to capture all output
|
|
73
73
|
const logFile = await initializeLogFile(null);
|
|
74
|
-
// Log version and raw command IMMEDIATELY after log file initialization
|
|
75
74
|
const versionInfo = await getVersionInfo();
|
|
76
75
|
await log('');
|
|
77
76
|
await log(`🚀 solve v${versionInfo}`);
|
|
@@ -80,6 +79,15 @@ await log('🔧 Raw command executed:');
|
|
|
80
79
|
await log(` ${rawCommand}`);
|
|
81
80
|
await log('');
|
|
82
81
|
|
|
82
|
+
let finalResourceSnapshotRecorded = false;
|
|
83
|
+
const safeExit = async (code = 0, reason = 'Process completed', options = {}) => {
|
|
84
|
+
if (!finalResourceSnapshotRecorded) {
|
|
85
|
+
finalResourceSnapshotRecorded = true;
|
|
86
|
+
await recordResourceSnapshot({ phase: RESOURCE_PHASE_SOLVE_EXIT, log, diskPath: '/', label: `solve exit ${code}` });
|
|
87
|
+
}
|
|
88
|
+
return await baseSafeExit(code, reason, options);
|
|
89
|
+
};
|
|
90
|
+
|
|
83
91
|
let argv;
|
|
84
92
|
try {
|
|
85
93
|
argv = await parseArguments(yargs, hideBin);
|
|
@@ -98,6 +106,7 @@ configureGitHubRateLimitLogging({
|
|
|
98
106
|
enabled: argv.githubRateLimitsLogging === true,
|
|
99
107
|
log,
|
|
100
108
|
});
|
|
109
|
+
await recordResourceSnapshot({ phase: RESOURCE_PHASE_SOLVE_START, log, diskPath: '/', label: 'solve start', logExecutionContext: true }); // #2001: detect+report container context
|
|
101
110
|
|
|
102
111
|
// Early logs go to cwd; custom log dir takes effect after argv is parsed
|
|
103
112
|
// Conditionally import tool-specific functions after argv is parsed
|
|
@@ -507,6 +516,7 @@ try {
|
|
|
507
516
|
});
|
|
508
517
|
|
|
509
518
|
cleanupContext.diskDiagnostics = { beforeBytes: await recordAfterCloneSize({ tempDir, log }) };
|
|
519
|
+
await recordResourceSnapshot({ phase: RESOURCE_PHASE_AFTER_CLONE, log, diskPath: '/', label: 'after repository clone' });
|
|
510
520
|
|
|
511
521
|
// Verify default branch and status using the new module
|
|
512
522
|
// Pass argv, owner, repo, issueUrl for empty repository auto-initialization (--auto-init-repository)
|
|
@@ -830,6 +840,7 @@ try {
|
|
|
830
840
|
} catch (diskError) {
|
|
831
841
|
await log(`⚠️ Disk-size measurement failed: ${cleanErrorMessage(diskError)}`, { level: 'warning', verbose: true });
|
|
832
842
|
}
|
|
843
|
+
await recordResourceSnapshot({ phase: RESOURCE_PHASE_AFTER_AGENT, log, diskPath: '/', label: 'after AI execution' });
|
|
833
844
|
|
|
834
845
|
// Issue #1823: Mark the end of the AI working session. If a graceful-shutdown interrupt arrived
|
|
835
846
|
// during the session (deferred by the working-session guard), honor it now: auto-commit any
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
export const RESOURCE_MARKER_PREFIX = '📈 [RESOURCES]';
|
|
5
|
+
|
|
6
|
+
export const RESOURCE_PHASE_SOLVE_START = 'solve_start';
|
|
7
|
+
export const RESOURCE_PHASE_AFTER_CLONE = 'after_clone';
|
|
8
|
+
export const RESOURCE_PHASE_AFTER_AGENT = 'after_agent';
|
|
9
|
+
export const RESOURCE_PHASE_SOLVE_EXIT = 'solve_exit';
|
|
10
|
+
export const RESOURCE_PHASE_RESTART_BEFORE = 'restart_before';
|
|
11
|
+
export const RESOURCE_PHASE_RESTART_AFTER = 'restart_after';
|
|
12
|
+
export const RESOURCE_PHASE_BOT_HEARTBEAT = 'bot_heartbeat';
|
|
13
|
+
|
|
14
|
+
const RESOURCE_PHASES_BY_PREFERENCE = [RESOURCE_PHASE_SOLVE_EXIT, RESOURCE_PHASE_AFTER_AGENT, RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_AFTER_CLONE, RESOURCE_PHASE_SOLVE_START, RESOURCE_PHASE_RESTART_BEFORE];
|
|
15
|
+
|
|
16
|
+
function finiteNumber(value) {
|
|
17
|
+
return Number.isFinite(value) ? value : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clampPercent(value) {
|
|
21
|
+
if (!Number.isFinite(value)) return null;
|
|
22
|
+
return Math.max(0, Math.min(100, value));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readLinuxMemAvailableBytes(readFileSync = fs.readFileSync, platform = process.platform) {
|
|
26
|
+
if (platform !== 'linux') return null;
|
|
27
|
+
try {
|
|
28
|
+
const text = readFileSync('/proc/meminfo', 'utf8');
|
|
29
|
+
const match = text.match(/^MemAvailable:\s+(\d+)\s+kB$/m);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
return Number.parseInt(match[1], 10) * 1024;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect where the current process is running so the solve command can scope
|
|
39
|
+
* per-task disk usage to the correct context (issue #2001).
|
|
40
|
+
*
|
|
41
|
+
* When solve runs inside a Docker/container isolation backend, the per-task
|
|
42
|
+
* disk measurement must come from container-scoped signals (the cloned working
|
|
43
|
+
* tree measured with `du` inside the container, plus the host-side
|
|
44
|
+
* `docker inspect --size` writable-layer size) rather than the whole-VM
|
|
45
|
+
* filesystem reported by `statfs('/')`. This helper makes that detection
|
|
46
|
+
* explicit and observable in the solve log.
|
|
47
|
+
*
|
|
48
|
+
* It is intentionally defensive: any injected `existsSync`/`readFileSync`
|
|
49
|
+
* implementation that lacks a method or throws is treated as "signal absent"
|
|
50
|
+
* rather than propagating an error, so callers never crash on detection.
|
|
51
|
+
*
|
|
52
|
+
* @returns {{ inContainer: boolean, runtime: string|null, indicators: string[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function detectExecutionContext(options = {}) {
|
|
55
|
+
const { existsSync = fs.existsSync, readFileSync = fs.readFileSync, env = process.env, platform = process.platform } = options;
|
|
56
|
+
|
|
57
|
+
const indicators = [];
|
|
58
|
+
let runtime = null;
|
|
59
|
+
|
|
60
|
+
const safeExists = target => {
|
|
61
|
+
try {
|
|
62
|
+
return typeof existsSync === 'function' ? existsSync(target) === true : false;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const safeRead = target => {
|
|
69
|
+
try {
|
|
70
|
+
return typeof readFileSync === 'function' ? String(readFileSync(target, 'utf8')) : '';
|
|
71
|
+
} catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (safeExists('/.dockerenv')) {
|
|
77
|
+
indicators.push('/.dockerenv');
|
|
78
|
+
runtime = runtime || 'docker';
|
|
79
|
+
}
|
|
80
|
+
if (safeExists('/run/.containerenv')) {
|
|
81
|
+
indicators.push('/run/.containerenv');
|
|
82
|
+
runtime = runtime || 'podman';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (platform === 'linux') {
|
|
86
|
+
const cgroup = safeRead('/proc/1/cgroup');
|
|
87
|
+
if (cgroup) {
|
|
88
|
+
if (/\bdocker\b/.test(cgroup)) {
|
|
89
|
+
indicators.push('cgroup:docker');
|
|
90
|
+
runtime = runtime || 'docker';
|
|
91
|
+
}
|
|
92
|
+
if (/kubepods/.test(cgroup)) {
|
|
93
|
+
indicators.push('cgroup:kubepods');
|
|
94
|
+
runtime = runtime || 'kubernetes';
|
|
95
|
+
}
|
|
96
|
+
if (/libpod|podman/.test(cgroup)) {
|
|
97
|
+
indicators.push('cgroup:podman');
|
|
98
|
+
runtime = runtime || 'podman';
|
|
99
|
+
}
|
|
100
|
+
if (/containerd/.test(cgroup)) {
|
|
101
|
+
indicators.push('cgroup:containerd');
|
|
102
|
+
runtime = runtime || 'containerd';
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const envObj = env && typeof env === 'object' ? env : {};
|
|
108
|
+
if (envObj.HIVE_MIND_ISOLATION || envObj.HIVE_MIND_CONTAINER) {
|
|
109
|
+
indicators.push('env:hive-mind-isolation');
|
|
110
|
+
runtime = runtime || 'container';
|
|
111
|
+
}
|
|
112
|
+
if (envObj.KUBERNETES_SERVICE_HOST) {
|
|
113
|
+
indicators.push('env:kubernetes');
|
|
114
|
+
runtime = runtime || 'kubernetes';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
inContainer: indicators.length > 0,
|
|
119
|
+
runtime,
|
|
120
|
+
indicators,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Human-readable one-liner describing the detected execution context, used to
|
|
126
|
+
* make the disk-usage scope explicit in the solve log.
|
|
127
|
+
*
|
|
128
|
+
* @param {ReturnType<typeof detectExecutionContext>} context
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function formatExecutionContextForLog(context) {
|
|
132
|
+
const ctx = context || detectExecutionContext();
|
|
133
|
+
if (ctx.inContainer) {
|
|
134
|
+
const runtime = ctx.runtime || 'container';
|
|
135
|
+
const detail = ctx.indicators.length ? ` (indicators: ${ctx.indicators.join(', ')})` : '';
|
|
136
|
+
return `🧭 Execution context: ${runtime} container${detail} — per-task disk usage is scoped to this container.`;
|
|
137
|
+
}
|
|
138
|
+
return '🧭 Execution context: host (no container isolation detected) — per-task disk usage is scoped to the working tree.';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function captureResourceSnapshot(options = {}) {
|
|
142
|
+
const { phase = 'snapshot', diskPath = '/', now = () => new Date(), osImpl = os, fsImpl = fs, processImpl = process } = options;
|
|
143
|
+
|
|
144
|
+
const timestamp = (() => {
|
|
145
|
+
try {
|
|
146
|
+
return now().toISOString();
|
|
147
|
+
} catch {
|
|
148
|
+
return new Date().toISOString();
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
|
|
152
|
+
const load = (() => {
|
|
153
|
+
try {
|
|
154
|
+
const values = osImpl.loadavg();
|
|
155
|
+
return {
|
|
156
|
+
load1: finiteNumber(values[0]),
|
|
157
|
+
load5: finiteNumber(values[1]),
|
|
158
|
+
load15: finiteNumber(values[2]),
|
|
159
|
+
};
|
|
160
|
+
} catch {
|
|
161
|
+
return { load1: null, load5: null, load15: null };
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
const cpuCount = (() => {
|
|
166
|
+
try {
|
|
167
|
+
const cpus = osImpl.cpus();
|
|
168
|
+
return Array.isArray(cpus) ? cpus.length : null;
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
|
|
174
|
+
const totalMemoryBytes = (() => {
|
|
175
|
+
try {
|
|
176
|
+
return finiteNumber(osImpl.totalmem());
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
|
|
182
|
+
const freeMemoryBytes = (() => {
|
|
183
|
+
try {
|
|
184
|
+
return finiteNumber(osImpl.freemem());
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
|
|
190
|
+
const availableMemoryBytes = readLinuxMemAvailableBytes(fsImpl.readFileSync?.bind(fsImpl), processImpl.platform || process.platform) ?? freeMemoryBytes;
|
|
191
|
+
const usedMemoryBytes = totalMemoryBytes !== null && availableMemoryBytes !== null ? Math.max(0, totalMemoryBytes - availableMemoryBytes) : null;
|
|
192
|
+
|
|
193
|
+
const processMemory = (() => {
|
|
194
|
+
try {
|
|
195
|
+
const usage = processImpl.memoryUsage();
|
|
196
|
+
return {
|
|
197
|
+
rssBytes: finiteNumber(usage.rss),
|
|
198
|
+
heapUsedBytes: finiteNumber(usage.heapUsed),
|
|
199
|
+
};
|
|
200
|
+
} catch {
|
|
201
|
+
return { rssBytes: null, heapUsedBytes: null };
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
|
|
205
|
+
const disk = (() => {
|
|
206
|
+
const path = String(diskPath || '/');
|
|
207
|
+
try {
|
|
208
|
+
if (typeof fsImpl.statfsSync !== 'function') {
|
|
209
|
+
return { path, totalBytes: null, freeBytes: null, availableBytes: null, usedBytes: null, usedPercent: null, error: 'statfs unavailable' };
|
|
210
|
+
}
|
|
211
|
+
const stat = fsImpl.statfsSync(path);
|
|
212
|
+
const blockSize = Number(stat.bsize || stat.frsize || 0);
|
|
213
|
+
const blocks = Number(stat.blocks);
|
|
214
|
+
const bfree = Number(stat.bfree);
|
|
215
|
+
const bavail = Number(stat.bavail);
|
|
216
|
+
const totalBytes = Number.isFinite(blockSize) && Number.isFinite(blocks) ? blockSize * blocks : null;
|
|
217
|
+
const freeBytes = Number.isFinite(blockSize) && Number.isFinite(bfree) ? blockSize * bfree : null;
|
|
218
|
+
const availableBytes = Number.isFinite(blockSize) && Number.isFinite(bavail) ? blockSize * bavail : freeBytes;
|
|
219
|
+
const usedBytes = totalBytes !== null && freeBytes !== null ? Math.max(0, totalBytes - freeBytes) : null;
|
|
220
|
+
const usedPercent = totalBytes && usedBytes !== null ? clampPercent((usedBytes / totalBytes) * 100) : null;
|
|
221
|
+
return { path, totalBytes, freeBytes, availableBytes, usedBytes, usedPercent, error: null };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return {
|
|
224
|
+
path,
|
|
225
|
+
totalBytes: null,
|
|
226
|
+
freeBytes: null,
|
|
227
|
+
availableBytes: null,
|
|
228
|
+
usedBytes: null,
|
|
229
|
+
usedPercent: null,
|
|
230
|
+
error: error?.message || String(error),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
phase: String(phase || 'snapshot'),
|
|
237
|
+
timestamp,
|
|
238
|
+
cpu: { ...load, cpuCount },
|
|
239
|
+
memory: {
|
|
240
|
+
totalBytes: totalMemoryBytes,
|
|
241
|
+
freeBytes: freeMemoryBytes,
|
|
242
|
+
availableBytes: availableMemoryBytes,
|
|
243
|
+
usedBytes: usedMemoryBytes,
|
|
244
|
+
processRssBytes: processMemory.rssBytes,
|
|
245
|
+
processHeapUsedBytes: processMemory.heapUsedBytes,
|
|
246
|
+
},
|
|
247
|
+
disk,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatBytes(bytes) {
|
|
252
|
+
if (!Number.isFinite(bytes)) return '? B';
|
|
253
|
+
const abs = Math.abs(bytes);
|
|
254
|
+
if (abs >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
|
255
|
+
if (abs >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
256
|
+
if (abs >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
257
|
+
return `${Math.round(bytes)} B`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatNumber(value, decimals = 2) {
|
|
261
|
+
return Number.isFinite(value) ? value.toFixed(decimals) : '?';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function encodeValue(value) {
|
|
265
|
+
if (value === null || value === undefined) return 'null';
|
|
266
|
+
return encodeURIComponent(String(value));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function numberField(name, value) {
|
|
270
|
+
return Number.isFinite(value) ? `${name}=${value}` : `${name}=null`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function buildResourceMarker(snapshot) {
|
|
274
|
+
const s = snapshot || {};
|
|
275
|
+
const cpu = s.cpu || {};
|
|
276
|
+
const memory = s.memory || {};
|
|
277
|
+
const disk = s.disk || {};
|
|
278
|
+
return [
|
|
279
|
+
RESOURCE_MARKER_PREFIX,
|
|
280
|
+
`phase=${encodeValue(s.phase || 'snapshot')}`,
|
|
281
|
+
`ts=${encodeValue(s.timestamp || new Date().toISOString())}`,
|
|
282
|
+
numberField('load1', cpu.load1),
|
|
283
|
+
numberField('load5', cpu.load5),
|
|
284
|
+
numberField('load15', cpu.load15),
|
|
285
|
+
numberField('cpuCount', cpu.cpuCount),
|
|
286
|
+
numberField('memTotalBytes', memory.totalBytes),
|
|
287
|
+
numberField('memAvailableBytes', memory.availableBytes),
|
|
288
|
+
numberField('memUsedBytes', memory.usedBytes),
|
|
289
|
+
numberField('processRssBytes', memory.processRssBytes),
|
|
290
|
+
`diskPath=${encodeValue(disk.path || '/')}`,
|
|
291
|
+
numberField('diskTotalBytes', disk.totalBytes),
|
|
292
|
+
numberField('diskAvailableBytes', disk.availableBytes),
|
|
293
|
+
numberField('diskUsedBytes', disk.usedBytes),
|
|
294
|
+
numberField('diskUsedPercent', disk.usedPercent),
|
|
295
|
+
disk.error ? `error=${encodeValue(disk.error)}` : null,
|
|
296
|
+
`mem=${encodeValue(`${formatBytes(memory.availableBytes)} available / ${formatBytes(memory.totalBytes)} total`)}`,
|
|
297
|
+
`disk=${encodeValue(`${formatBytes(disk.availableBytes)} available / ${formatBytes(disk.totalBytes)} total`)}`,
|
|
298
|
+
]
|
|
299
|
+
.filter(Boolean)
|
|
300
|
+
.join(' ');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function parseNumber(value) {
|
|
304
|
+
if (value === 'null' || value === undefined) return null;
|
|
305
|
+
const n = Number(value);
|
|
306
|
+
return Number.isFinite(n) ? n : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseMarkerLine(line) {
|
|
310
|
+
const idx = line.indexOf(RESOURCE_MARKER_PREFIX);
|
|
311
|
+
if (idx < 0) return null;
|
|
312
|
+
const payload = line.slice(idx + RESOURCE_MARKER_PREFIX.length).trim();
|
|
313
|
+
const parts = payload.split(/\s+/).filter(Boolean);
|
|
314
|
+
const fields = {};
|
|
315
|
+
for (const part of parts) {
|
|
316
|
+
const eq = part.indexOf('=');
|
|
317
|
+
if (eq <= 0) continue;
|
|
318
|
+
fields[part.slice(0, eq)] = part.slice(eq + 1);
|
|
319
|
+
}
|
|
320
|
+
const phase = decodeURIComponent(fields.phase || 'snapshot');
|
|
321
|
+
return {
|
|
322
|
+
phase,
|
|
323
|
+
timestamp: decodeURIComponent(fields.ts || ''),
|
|
324
|
+
cpu: {
|
|
325
|
+
load1: parseNumber(fields.load1),
|
|
326
|
+
load5: parseNumber(fields.load5),
|
|
327
|
+
load15: parseNumber(fields.load15),
|
|
328
|
+
cpuCount: parseNumber(fields.cpuCount),
|
|
329
|
+
},
|
|
330
|
+
memory: {
|
|
331
|
+
totalBytes: parseNumber(fields.memTotalBytes),
|
|
332
|
+
availableBytes: parseNumber(fields.memAvailableBytes),
|
|
333
|
+
usedBytes: parseNumber(fields.memUsedBytes),
|
|
334
|
+
processRssBytes: parseNumber(fields.processRssBytes),
|
|
335
|
+
},
|
|
336
|
+
disk: {
|
|
337
|
+
path: decodeURIComponent(fields.diskPath || '/'),
|
|
338
|
+
totalBytes: parseNumber(fields.diskTotalBytes),
|
|
339
|
+
availableBytes: parseNumber(fields.diskAvailableBytes),
|
|
340
|
+
usedBytes: parseNumber(fields.diskUsedBytes),
|
|
341
|
+
usedPercent: parseNumber(fields.diskUsedPercent),
|
|
342
|
+
error: fields.error ? decodeURIComponent(fields.error) : null,
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function parseResourceMarkers(logText) {
|
|
348
|
+
if (typeof logText !== 'string' || !logText) return { markers: [], byPhase: {} };
|
|
349
|
+
const markers = [];
|
|
350
|
+
const byPhase = {};
|
|
351
|
+
for (const line of logText.split(/\r?\n/)) {
|
|
352
|
+
const marker = parseMarkerLine(line);
|
|
353
|
+
if (!marker) continue;
|
|
354
|
+
markers.push(marker);
|
|
355
|
+
byPhase[marker.phase] = marker;
|
|
356
|
+
}
|
|
357
|
+
return { markers, byPhase };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function selectBestDiskResourceMarker(parsed) {
|
|
361
|
+
const byPhase = parsed?.byPhase || {};
|
|
362
|
+
for (const phase of RESOURCE_PHASES_BY_PREFERENCE) {
|
|
363
|
+
const marker = byPhase[phase];
|
|
364
|
+
if (Number.isFinite(marker?.disk?.usedBytes)) return marker;
|
|
365
|
+
}
|
|
366
|
+
const markers = Array.isArray(parsed?.markers) ? parsed.markers : [];
|
|
367
|
+
for (let i = markers.length - 1; i >= 0; i--) {
|
|
368
|
+
if (Number.isFinite(markers[i]?.disk?.usedBytes)) return markers[i];
|
|
369
|
+
}
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function formatResourceSnapshotForLog(snapshot, label = null) {
|
|
374
|
+
const s = snapshot || {};
|
|
375
|
+
const phaseLabel = label || String(s.phase || 'snapshot').replace(/_/g, ' ');
|
|
376
|
+
const cpu = s.cpu || {};
|
|
377
|
+
const memory = s.memory || {};
|
|
378
|
+
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)}${Number.isFinite(memory.processHeapUsedBytes) ? `, heap ${formatBytes(memory.processHeapUsedBytes)}` : ''}`, ` Disk (${disk.path || '/'}): ${formatBytes(disk.availableBytes)} available / ${formatBytes(disk.totalBytes)} total${Number.isFinite(disk.usedPercent) ? ` (${disk.usedPercent.toFixed(1)}% used)` : ''}`];
|
|
380
|
+
if (disk.error) lines.push(` Disk probe error: ${disk.error}`);
|
|
381
|
+
lines.push(buildResourceMarker(snapshot));
|
|
382
|
+
return lines.join('\n');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function recordResourceSnapshot({ phase, log, diskPath = '/', label = null, capture = captureResourceSnapshot, logExecutionContext = false, detectContext = detectExecutionContext } = {}) {
|
|
386
|
+
if (typeof log !== 'function') return null;
|
|
387
|
+
try {
|
|
388
|
+
// Issue #2001: optionally report the execution context (host vs container)
|
|
389
|
+
// so it is explicit that per-task disk usage is scoped to the container.
|
|
390
|
+
if (logExecutionContext) {
|
|
391
|
+
try {
|
|
392
|
+
await log(formatExecutionContextForLog(detectContext()));
|
|
393
|
+
} catch {
|
|
394
|
+
/* context detection is best-effort and must never block the snapshot */
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const snapshot = capture({ phase, diskPath });
|
|
398
|
+
await log(formatResourceSnapshotForLog(snapshot, label));
|
|
399
|
+
return snapshot;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
await log(`⚠️ Resource usage measurement failed (${phase || 'snapshot'}): ${error?.message || error}`, { level: 'warning', verbose: true });
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function summarizeResourceSnapshot(snapshot) {
|
|
407
|
+
if (!snapshot) return null;
|
|
408
|
+
const cpu = snapshot.cpu || {};
|
|
409
|
+
const memory = snapshot.memory || {};
|
|
410
|
+
const disk = snapshot.disk || {};
|
|
411
|
+
return {
|
|
412
|
+
phase: snapshot.phase || null,
|
|
413
|
+
timestamp: snapshot.timestamp || null,
|
|
414
|
+
cpu: {
|
|
415
|
+
load1: cpu.load1,
|
|
416
|
+
load5: cpu.load5,
|
|
417
|
+
load15: cpu.load15,
|
|
418
|
+
cpuCount: cpu.cpuCount,
|
|
419
|
+
},
|
|
420
|
+
memory: {
|
|
421
|
+
totalBytes: memory.totalBytes,
|
|
422
|
+
availableBytes: memory.availableBytes,
|
|
423
|
+
usedBytes: memory.usedBytes,
|
|
424
|
+
processRssBytes: memory.processRssBytes,
|
|
425
|
+
},
|
|
426
|
+
disk: {
|
|
427
|
+
path: disk.path,
|
|
428
|
+
totalBytes: disk.totalBytes,
|
|
429
|
+
availableBytes: disk.availableBytes,
|
|
430
|
+
usedBytes: disk.usedBytes,
|
|
431
|
+
usedPercent: disk.usedPercent,
|
|
432
|
+
error: disk.error || null,
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
}
|
|
@@ -32,6 +32,7 @@ const fs = (await use('fs')).promises;
|
|
|
32
32
|
const lib = await import('./lib.mjs');
|
|
33
33
|
const { log, formatAligned, extractToolErrorCore } = lib;
|
|
34
34
|
const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
|
|
35
|
+
const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
|
|
35
36
|
|
|
36
37
|
// Import Sentry integration
|
|
37
38
|
const sentryLib = await import('./sentry.lib.mjs');
|
|
@@ -177,6 +178,13 @@ export const getUncommittedChangesDetails = async tempDir => {
|
|
|
177
178
|
export const executeToolIteration = async params => {
|
|
178
179
|
const { issueUrl, owner, repo, issueNumber, prNumber, branchName, tempDir, workspaceTmpDir, mergeStateStatus, feedbackLines, argv } = params;
|
|
179
180
|
|
|
181
|
+
await recordResourceSnapshot({
|
|
182
|
+
phase: RESOURCE_PHASE_RESTART_BEFORE,
|
|
183
|
+
log,
|
|
184
|
+
diskPath: '/',
|
|
185
|
+
label: 'before AI restart iteration',
|
|
186
|
+
});
|
|
187
|
+
|
|
180
188
|
// Import necessary modules for tool execution
|
|
181
189
|
const memoryCheck = await import('./memory-check.mjs');
|
|
182
190
|
const { getResourceSnapshot } = memoryCheck;
|
|
@@ -462,6 +470,12 @@ export const executeToolIteration = async params => {
|
|
|
462
470
|
}
|
|
463
471
|
|
|
464
472
|
await ensurePullRequestBaseBranch({ owner, repo, prNumber, argv, log, formatAligned, $ });
|
|
473
|
+
await recordResourceSnapshot({
|
|
474
|
+
phase: RESOURCE_PHASE_RESTART_AFTER,
|
|
475
|
+
log,
|
|
476
|
+
diskPath: '/',
|
|
477
|
+
label: 'after AI restart iteration',
|
|
478
|
+
});
|
|
465
479
|
return toolResult;
|
|
466
480
|
};
|
|
467
481
|
|