@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.28",
3
+ "version": "2.1.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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 */
@@ -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
- * and Opus 4.8 (Issue #1832, Issue #1875).
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 Mythos 5 support up to 128k output tokens, matching
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 Mythos 5 are adaptive-thinking-only too: extended/manual
568
- // thinking is unavailable and `thinking: {type: "disabled"}` is rejected, so a
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;
@@ -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-4-6', // Sonnet 4.6 (default, Issue #1329)
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 4.6 (Issue #1329)
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
- if (sessionStore) {
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 = await getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, {
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
@@ -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