@link-assistant/hive-mind 2.0.6 → 2.0.7

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,28 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 6d9a2bb: feat(solve): log working-tree size before/after the AI agent and warn on Telegram when disk usage exceeds 5 GB (#1945)
8
+
9
+ `/solve` now records the size of its temporary working tree at two checkpoints:
10
+ after the repository is cloned (before the AI agent starts) and after the AI
11
+ working session ends. Both checkpoints emit a structured `📊 [DISK]` marker into
12
+ the captured solve log, so the cloned-repo size, the AI-induced delta, and the
13
+ final total are visible in `tail -f`-style debugging.
14
+
15
+ The session monitor parses those markers from the captured log and appends a
16
+ `💾 Disk usage` block to the Telegram completion message. The block raises a
17
+ warning when the cloned repository exceeds 5 GB, when the working tree grew by
18
+ more than 5 GB during the run, or when the total disk usage for the task
19
+ exceeds 5 GB — exactly the three conditions called out in the issue.
20
+
21
+ Sizing uses `du -sb` (byte-accurate on Linux), falls back to `du -sk` on BSD/
22
+ macOS, and finally to `fs.statSync` for single-file targets — no new runtime
23
+ dependency. The threshold is 5 GiB and uses a strict `>` comparison, so a tree
24
+ that lands at exactly 5 GiB does not warn.
25
+
3
26
  ## 2.0.6
4
27
 
5
28
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -297,6 +297,28 @@ async function resolvePullRequestUrlFromSessionLog(logPath, ctx, { verbose = fal
297
297
  }
298
298
  }
299
299
 
300
+ /**
301
+ * Issue #1945: Parse `📊 [DISK]` checkpoint markers out of the captured solve
302
+ * log and, when the captured sizes cross the 5 GB threshold(s), build a
303
+ * Telegram extraSection that warns the operator. Returns an empty string if
304
+ * the log is unreadable or contains no markers.
305
+ */
306
+ async function buildDiskDiagnosticsExtraSection(logPath, { verbose = false, readFile = fs.readFile } = {}) {
307
+ if (!logPath) return '';
308
+ try {
309
+ const diskLib = await import('./solve.disk-diagnostics.lib.mjs');
310
+ const logText = await readFile(logPath, 'utf8');
311
+ const parsed = diskLib.parseDiskMarkers(logText);
312
+ if (!parsed.afterClone && !parsed.afterAgent) return '';
313
+ return diskLib.formatDiskDiagnosticsBlock(parsed);
314
+ } catch (error) {
315
+ if (verbose) {
316
+ console.log(`[VERBOSE] Could not inspect session log ${logPath} for disk diagnostics: ${error?.message || error}`);
317
+ }
318
+ return '';
319
+ }
320
+ }
321
+
300
322
  function isNonIsolationSessionActive(sessionName, sessionInfo, verbose = false) {
301
323
  const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
302
324
  const elapsed = Date.now() - startTime.getTime();
@@ -648,6 +670,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
648
670
  }
649
671
  }
650
672
 
673
+ // Issue #1945: append a "💾 Disk usage" block (with warnings when the
674
+ // cloned repo, the delta during the run, or the total exceed 5 GB)
675
+ // parsed from the captured solve log markers.
676
+ const diskExtraSections = [];
677
+ try {
678
+ const diskLogPath = statusResult?.logPath || sessionInfo?.logPath || null;
679
+ const diskBlock = await buildDiskDiagnosticsExtraSection(diskLogPath, { verbose });
680
+ if (diskBlock) diskExtraSections.push(diskBlock);
681
+ } catch (diskError) {
682
+ if (verbose) {
683
+ console.log(`[VERBOSE] Could not build disk diagnostics section for ${sessionName}: ${diskError?.message || diskError}`);
684
+ }
685
+ }
686
+
651
687
  const message = formatSessionCompletionMessage({
652
688
  sessionName,
653
689
  sessionInfo,
@@ -656,7 +692,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
656
692
  exitCode: finalExitCode,
657
693
  infoBlock: sessionInfo?.infoBlock || '',
658
694
  pullRequestUrl,
659
- extraSections: [...limitsExtraSections, ...resumeExtraSections],
695
+ extraSections: [...limitsExtraSections, ...resumeExtraSections, ...diskExtraSections],
660
696
  });
661
697
 
662
698
  // Update the original reply message if messageId is available, otherwise send new message
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Disk-space diagnostics for the `/solve` command (issue #1945).
3
+ *
4
+ * Captures two checkpoints around the AI working session:
5
+ *
6
+ * 1. AFTER_CLONE — size of the freshly-cloned `tempDir` BEFORE the AI agent
7
+ * starts. Tells us how large the repository itself is.
8
+ * 2. AFTER_AGENT — size of the same `tempDir` AFTER the AI agent has
9
+ * finished, so we can see how many bytes the working session added.
10
+ *
11
+ * Both checkpoints are written to the captured solve log as a single-line
12
+ * structured marker. The Telegram bot's `session-monitor.lib.mjs` parses those
13
+ * markers and, on the completion message, surfaces a Telegram block plus
14
+ * warnings when any of the three thresholds from the issue are crossed:
15
+ *
16
+ * - cloned repository > WARNING_THRESHOLD_BYTES
17
+ * - delta during run > WARNING_THRESHOLD_BYTES
18
+ * - total space used > WARNING_THRESHOLD_BYTES
19
+ *
20
+ * Implementation notes:
21
+ *
22
+ * - Uses `du -sb <path>` on Linux for byte-accurate sizing, falls back to
23
+ * `du -sk <path>` (kilobytes ×1024) on systems without GNU coreutils
24
+ * (macOS BSD `du` doesn't support `-b`). A final fs.statSync fallback
25
+ * keeps the helper non-throwing for plain files / inaccessible dirs.
26
+ * - The marker format is deliberately ASCII and key=value so it survives
27
+ * log truncation and stays parseable with a one-line regex. We DO NOT
28
+ * emit JSON because the existing log is human-tailing-friendly and a
29
+ * stray closing brace from another logger could confuse JSON.parse.
30
+ *
31
+ * @see https://github.com/link-assistant/hive-mind/issues/1945
32
+ */
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+ import fs from 'node:fs';
36
+
37
+ /** 5 GB threshold (binary). Matches the issue body verbatim. */
38
+ export const WARNING_THRESHOLD_BYTES = 5 * 1024 * 1024 * 1024;
39
+
40
+ export const DISK_MARKER_PREFIX = '📊 [DISK]';
41
+ export const DISK_PHASE_AFTER_CLONE = 'after_clone';
42
+ export const DISK_PHASE_AFTER_AGENT = 'after_agent';
43
+
44
+ /**
45
+ * Measure the size of a path in bytes. Robust to missing tools / paths.
46
+ *
47
+ * @param {string} targetPath
48
+ * @returns {number|null} Bytes, or null if the path is missing/unreadable.
49
+ */
50
+ export function measureDirectorySize(targetPath) {
51
+ if (!targetPath) return null;
52
+ // Prefer `du -sb` (GNU coreutils) for byte-accurate sizing.
53
+ try {
54
+ const out = execFileSync('du', ['-sb', targetPath], {
55
+ encoding: 'utf8',
56
+ stdio: ['ignore', 'pipe', 'ignore'],
57
+ timeout: 60_000,
58
+ maxBuffer: 4 * 1024 * 1024,
59
+ }).trim();
60
+ const bytes = parseInt(out.split(/\s+/)[0], 10);
61
+ if (Number.isFinite(bytes) && bytes >= 0) return bytes;
62
+ } catch {
63
+ // Fall through to -sk fallback for BSD du / macOS.
64
+ }
65
+ // BSD `du` (macOS) doesn't support -b but does support -sk (kilobytes).
66
+ try {
67
+ const out = execFileSync('du', ['-sk', targetPath], {
68
+ encoding: 'utf8',
69
+ stdio: ['ignore', 'pipe', 'ignore'],
70
+ timeout: 60_000,
71
+ maxBuffer: 4 * 1024 * 1024,
72
+ }).trim();
73
+ const kb = parseInt(out.split(/\s+/)[0], 10);
74
+ if (Number.isFinite(kb) && kb >= 0) return kb * 1024;
75
+ } catch {
76
+ // Fall through to fs.statSync — last resort for single-file paths.
77
+ }
78
+ try {
79
+ const stat = fs.statSync(targetPath);
80
+ return stat.size;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Human-readable byte format. Two flavours:
88
+ * - `formatBytes(bytes)` → `"12.0 GB"` (matches limits.lib.mjs style)
89
+ * - `formatBytesCompact(b)` → `"12G"` (matches the issue body verbatim
90
+ * and cleanup.lib.mjs)
91
+ *
92
+ * @param {number|null|undefined} bytes
93
+ * @returns {string}
94
+ */
95
+ export function formatBytes(bytes) {
96
+ if (bytes == null || Number.isNaN(bytes)) return '? B';
97
+ if (bytes < 1024) return `${bytes} B`;
98
+ const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
99
+ let value = bytes / 1024;
100
+ let unit = 0;
101
+ while (value >= 1024 && unit < units.length - 1) {
102
+ value /= 1024;
103
+ unit++;
104
+ }
105
+ // 1 decimal for GB and above (matches limits.lib formatBytes), none below.
106
+ const decimals = units[unit] === 'GB' || units[unit] === 'TB' || units[unit] === 'PB' ? 1 : 0;
107
+ return `${value.toFixed(decimals)} ${units[unit]}`;
108
+ }
109
+
110
+ /**
111
+ * Signed byte delta — adds a leading "+" for positive non-zero values so a
112
+ * growth like 500 MB renders as "+500 MB" in both logs and Telegram.
113
+ */
114
+ export function formatBytesDelta(bytes) {
115
+ if (bytes == null || Number.isNaN(bytes)) return '? B';
116
+ if (bytes === 0) return '±0 B';
117
+ const sign = bytes > 0 ? '+' : '-';
118
+ return `${sign}${formatBytes(Math.abs(bytes))}`;
119
+ }
120
+
121
+ function escapeForMarker(value) {
122
+ // Strip newlines and the marker prefix so a path containing the literal
123
+ // "📊 [DISK]" cannot inject a fake marker. Paths almost never contain spaces
124
+ // in /tmp but we still quote with backticks for the human-readable suffix
125
+ // and use key=value pairs for the parseable head.
126
+ return String(value)
127
+ .replace(/[\r\n]+/g, ' ')
128
+ .slice(0, 2048);
129
+ }
130
+
131
+ /**
132
+ * Build a single-line structured log marker the parent (Telegram bot) can
133
+ * parse out of the captured log to surface size warnings.
134
+ *
135
+ * Example (after_clone):
136
+ * 📊 [DISK] phase=after_clone bytes=12884901888 path=/tmp/foo size=12.0 GB
137
+ *
138
+ * Example (after_agent):
139
+ * 📊 [DISK] phase=after_agent bytes=13312000000 deltaBytes=524288000 path=/tmp/foo size=12.4 GB delta=+500.0 MB
140
+ *
141
+ * @param {Object} params
142
+ * @param {string} params.phase - 'after_clone' | 'after_agent'
143
+ * @param {number|null} params.bytes - Current size of tempDir in bytes
144
+ * @param {number|null} [params.deltaBytes] - Bytes added since after_clone (after_agent only)
145
+ * @param {string} params.path - The measured path
146
+ * @returns {string}
147
+ */
148
+ export function buildDiskMarker({ phase, bytes, deltaBytes = null, path: targetPath }) {
149
+ const head = [`phase=${phase}`];
150
+ if (Number.isFinite(bytes)) head.push(`bytes=${bytes}`);
151
+ if (Number.isFinite(deltaBytes)) head.push(`deltaBytes=${deltaBytes}`);
152
+ head.push(`path=${escapeForMarker(targetPath || '')}`);
153
+ const suffixParts = [];
154
+ if (Number.isFinite(bytes)) suffixParts.push(`size=${formatBytes(bytes)}`);
155
+ if (Number.isFinite(deltaBytes)) suffixParts.push(`delta=${formatBytesDelta(deltaBytes)}`);
156
+ const suffix = suffixParts.length ? ` ${suffixParts.join(' ')}` : '';
157
+ return `${DISK_MARKER_PREFIX} ${head.join(' ')}${suffix}`;
158
+ }
159
+
160
+ /**
161
+ * Parse all `📊 [DISK]` markers out of a captured solve log. The LAST marker
162
+ * for each phase wins (sessions that restart can emit more than one).
163
+ *
164
+ * @param {string} logText
165
+ * @returns {{
166
+ * afterClone: {bytes:number|null, path:string|null} | null,
167
+ * afterAgent: {bytes:number|null, deltaBytes:number|null, path:string|null} | null
168
+ * }}
169
+ */
170
+ export function parseDiskMarkers(logText) {
171
+ const result = { afterClone: null, afterAgent: null };
172
+ if (!logText || typeof logText !== 'string') return result;
173
+ // Anchor to the marker prefix so a quoted user comment containing this
174
+ // string mid-line is not mistakenly parsed.
175
+ const re = /📊 \[DISK\] ([^\n\r]+)/g;
176
+ let m;
177
+ while ((m = re.exec(logText)) !== null) {
178
+ const pairs = {};
179
+ // key=value tokens, where value runs until next " key=" or EOL.
180
+ const tokenRe = /(\w+)=([^\s][^\n\r]*?)(?=\s+\w+=|$)/g;
181
+ let t;
182
+ while ((t = tokenRe.exec(m[1])) !== null) {
183
+ pairs[t[1]] = t[2];
184
+ }
185
+ const phase = pairs.phase;
186
+ if (phase !== DISK_PHASE_AFTER_CLONE && phase !== DISK_PHASE_AFTER_AGENT) continue;
187
+ const bytes = parseInt(pairs.bytes, 10);
188
+ const deltaBytes = parseInt(pairs.deltaBytes, 10);
189
+ const entry = {
190
+ bytes: Number.isFinite(bytes) ? bytes : null,
191
+ path: pairs.path || null,
192
+ };
193
+ if (phase === DISK_PHASE_AFTER_AGENT) {
194
+ entry.deltaBytes = Number.isFinite(deltaBytes) ? deltaBytes : null;
195
+ result.afterAgent = entry;
196
+ } else {
197
+ result.afterClone = entry;
198
+ }
199
+ }
200
+ return result;
201
+ }
202
+
203
+ /**
204
+ * Decide which of the three issue thresholds were crossed.
205
+ *
206
+ * @param {{afterClone: object|null, afterAgent: object|null}} parsed
207
+ * @param {number} [threshold=WARNING_THRESHOLD_BYTES]
208
+ * @returns {{cloneTooLarge:boolean, deltaTooLarge:boolean, totalTooLarge:boolean}}
209
+ */
210
+ export function computeDiskWarnings(parsed, threshold = WARNING_THRESHOLD_BYTES) {
211
+ const cloneBytes = parsed?.afterClone?.bytes ?? null;
212
+ const totalBytes = parsed?.afterAgent?.bytes ?? cloneBytes;
213
+ const deltaBytes = parsed?.afterAgent?.deltaBytes ?? null;
214
+ return {
215
+ cloneTooLarge: Number.isFinite(cloneBytes) && cloneBytes > threshold,
216
+ deltaTooLarge: Number.isFinite(deltaBytes) && deltaBytes > threshold,
217
+ totalTooLarge: Number.isFinite(totalBytes) && totalBytes > threshold,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Telegram block (Markdown code fence) describing the captured sizes plus,
223
+ * when any threshold is crossed, a `⚠️ Warnings:` tail. Returns an empty
224
+ * string when there are no markers in the log (no logs ⇒ no surprise output).
225
+ *
226
+ * Returned shape:
227
+ *
228
+ * 💾 Disk usage (gh-issue-solver-…)
229
+ * ```
230
+ * Cloned repository: 12.0 GB
231
+ * After agent: 12.4 GB (+500.0 MB)
232
+ * Threshold: 5.0 GB
233
+ *
234
+ * ⚠️ Cloned repository exceeds 5.0 GB
235
+ * ⚠️ Total disk usage exceeds 5.0 GB
236
+ * ```
237
+ *
238
+ * @param {{afterClone: object|null, afterAgent: object|null}} parsed
239
+ * @param {Object} [options]
240
+ * @param {number} [options.threshold=WARNING_THRESHOLD_BYTES]
241
+ * @param {string} [options.title='💾 Disk usage']
242
+ * @returns {string}
243
+ */
244
+ export function formatDiskDiagnosticsBlock(parsed, options = {}) {
245
+ if (!parsed || (!parsed.afterClone && !parsed.afterAgent)) return '';
246
+ const threshold = Number.isFinite(options.threshold) ? options.threshold : WARNING_THRESHOLD_BYTES;
247
+ const title = options.title || '💾 Disk usage';
248
+ const warnings = computeDiskWarnings(parsed, threshold);
249
+ const lines = [];
250
+ const cloneBytes = parsed.afterClone?.bytes ?? null;
251
+ const totalBytes = parsed.afterAgent?.bytes ?? null;
252
+ const deltaBytes = parsed.afterAgent?.deltaBytes ?? null;
253
+ if (cloneBytes !== null) {
254
+ lines.push(`Cloned repository: ${formatBytes(cloneBytes)}`);
255
+ }
256
+ if (totalBytes !== null) {
257
+ const deltaStr = deltaBytes !== null ? ` (${formatBytesDelta(deltaBytes)})` : '';
258
+ lines.push(`After agent: ${formatBytes(totalBytes)}${deltaStr}`);
259
+ } else if (deltaBytes !== null) {
260
+ lines.push(`Delta during run: ${formatBytesDelta(deltaBytes)}`);
261
+ }
262
+ lines.push(`Threshold: ${formatBytes(threshold)}`);
263
+ const warningLines = [];
264
+ if (warnings.cloneTooLarge) warningLines.push(`⚠️ Cloned repository exceeds ${formatBytes(threshold)}`);
265
+ if (warnings.deltaTooLarge) warningLines.push(`⚠️ Folder grew by more than ${formatBytes(threshold)} during the run`);
266
+ if (warnings.totalTooLarge) warningLines.push(`⚠️ Total disk usage exceeds ${formatBytes(threshold)}`);
267
+ if (warningLines.length) {
268
+ lines.push('');
269
+ lines.push(...warningLines);
270
+ }
271
+ return `${title}\n\`\`\`\n${lines.join('\n')}\n\`\`\``;
272
+ }
273
+
274
+ /**
275
+ * Capture the AFTER_CLONE checkpoint and log it. Safe to call when `log`
276
+ * is missing; degrades to console.log so a CLI-only run still shows the size.
277
+ *
278
+ * Returns the captured size in bytes so the caller can stash it for the
279
+ * AFTER_AGENT delta calculation, or null if measurement failed.
280
+ *
281
+ * @param {Object} params
282
+ * @param {string} params.tempDir
283
+ * @param {Function} [params.log] - The bound `log` from solve.mjs
284
+ * @returns {Promise<number|null>}
285
+ */
286
+ export async function recordAfterCloneSize({ tempDir, log }) {
287
+ const bytes = measureDirectorySize(tempDir);
288
+ const marker = buildDiskMarker({
289
+ phase: DISK_PHASE_AFTER_CLONE,
290
+ bytes,
291
+ path: tempDir,
292
+ });
293
+ if (log) {
294
+ await log(`\n${marker}`);
295
+ } else {
296
+ console.log(marker);
297
+ }
298
+ return bytes;
299
+ }
300
+
301
+ /**
302
+ * Capture the AFTER_AGENT checkpoint and log it (with delta versus the
303
+ * AFTER_CLONE checkpoint when available). Returns the captured size in bytes.
304
+ *
305
+ * @param {Object} params
306
+ * @param {string} params.tempDir
307
+ * @param {number|null} params.beforeBytes - The AFTER_CLONE size captured earlier
308
+ * @param {Function} [params.log]
309
+ * @returns {Promise<number|null>}
310
+ */
311
+ export async function recordAfterAgentSize({ tempDir, beforeBytes, log }) {
312
+ const bytes = measureDirectorySize(tempDir);
313
+ const deltaBytes = Number.isFinite(bytes) && Number.isFinite(beforeBytes) ? bytes - beforeBytes : null;
314
+ const marker = buildDiskMarker({
315
+ phase: DISK_PHASE_AFTER_AGENT,
316
+ bytes,
317
+ deltaBytes,
318
+ path: tempDir,
319
+ });
320
+ if (log) {
321
+ await log(`\n${marker}`);
322
+ } else {
323
+ console.log(marker);
324
+ }
325
+ return bytes;
326
+ }
327
+
328
+ export default {
329
+ WARNING_THRESHOLD_BYTES,
330
+ DISK_MARKER_PREFIX,
331
+ DISK_PHASE_AFTER_CLONE,
332
+ DISK_PHASE_AFTER_AGENT,
333
+ measureDirectorySize,
334
+ formatBytes,
335
+ formatBytesDelta,
336
+ buildDiskMarker,
337
+ parseDiskMarkers,
338
+ computeDiskWarnings,
339
+ formatDiskDiagnosticsBlock,
340
+ recordAfterCloneSize,
341
+ recordAfterAgentSize,
342
+ };
package/src/solve.mjs CHANGED
@@ -55,6 +55,7 @@ const { configureWorkingSession, beginWorkingSession, endWorkingSession } = awai
55
55
  const getResourceSnapshot = memoryCheck.getResourceSnapshot;
56
56
  const { handleAutoPrCreation } = await import('./solve.auto-pr.lib.mjs');
57
57
  const { setupRepositoryAndClone, verifyDefaultBranchAndStatus } = await import('./solve.repo-setup.lib.mjs');
58
+ const { recordAfterCloneSize, recordAfterAgentSize } = await import('./solve.disk-diagnostics.lib.mjs');
58
59
  const { createOrCheckoutBranch } = await import('./solve.branch.lib.mjs');
59
60
  const { startWorkSession, endWorkSession, SESSION_TYPES } = await import('./solve.session.lib.mjs');
60
61
  // Issue #1625: centralized markers + tracked comment posting for solve.mjs's
@@ -501,6 +502,8 @@ try {
501
502
  needsClone,
502
503
  });
503
504
 
505
+ cleanupContext.diskDiagnostics = { beforeBytes: await recordAfterCloneSize({ tempDir, log }) };
506
+
504
507
  // Verify default branch and status using the new module
505
508
  // Pass argv, owner, repo, issueUrl for empty repository auto-initialization (--auto-init-repository)
506
509
  const defaultBranch = await verifyDefaultBranchAndStatus({
@@ -814,6 +817,12 @@ try {
814
817
  toolResult = claudeResult;
815
818
  }
816
819
 
820
+ try {
821
+ await recordAfterAgentSize({ tempDir, beforeBytes: cleanupContext.diskDiagnostics?.beforeBytes ?? null, log });
822
+ } catch (diskError) {
823
+ await log(`⚠️ Disk-size measurement failed: ${cleanErrorMessage(diskError)}`, { level: 'warning', verbose: true });
824
+ }
825
+
817
826
  // Issue #1823: Mark the end of the AI working session. If a graceful-shutdown interrupt arrived
818
827
  // during the session (deferred by the working-session guard), honor it now: auto-commit any
819
828
  // uncommitted changes and exit gracefully — only AFTER the AI tool has fully finished its turn.