@link-assistant/hive-mind 2.15.2 → 2.17.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +63 -5
  11. package/src/child-exit.lib.mjs +53 -1
  12. package/src/claude.session-tokens.lib.mjs +10 -8
  13. package/src/claude.session-transcript-repair.lib.mjs +65 -34
  14. package/src/cleanup.mjs +57 -3
  15. package/src/codex.lib.mjs +11 -1
  16. package/src/development-log.lib.mjs +22 -7
  17. package/src/disk-guard.lib.mjs +21 -1
  18. package/src/formal-ai-version.lib.mjs +10 -6
  19. package/src/github-error-reporter.lib.mjs +84 -2
  20. package/src/github.lib.mjs +212 -152
  21. package/src/instrument.mjs +12 -14
  22. package/src/instrument.sanitize.lib.mjs +52 -0
  23. package/src/isolation-runner.lib.mjs +56 -33
  24. package/src/isolation-runner.parsers.lib.mjs +29 -3
  25. package/src/isolation-runner.resume.lib.mjs +263 -0
  26. package/src/log-bounded-read.lib.mjs +411 -0
  27. package/src/log-sanitize-stream.lib.mjs +267 -0
  28. package/src/log-sanitize-worker-entry.mjs +31 -0
  29. package/src/log-sanitize-worker.lib.mjs +186 -0
  30. package/src/log-upload.lib.mjs +16 -4
  31. package/src/pull-request-changes.lib.mjs +1 -1
  32. package/src/session-completion-state.lib.mjs +124 -0
  33. package/src/session-kill-diagnostics.lib.mjs +117 -12
  34. package/src/session-kill-policy.lib.mjs +18 -7
  35. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  36. package/src/session-kill-resume.lib.mjs +48 -18
  37. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  38. package/src/session-monitor.lib.mjs +132 -9
  39. package/src/session-store.lib.mjs +15 -1
  40. package/src/solve.clone-errors.lib.mjs +86 -0
  41. package/src/solve.config.lib.mjs +5 -2
  42. package/src/solve.repository.lib.mjs +36 -63
  43. package/src/solve.resource-diagnostics.lib.mjs +105 -5
  44. package/src/start-command-cli.lib.mjs +60 -0
  45. package/src/telegram-bot.mjs +31 -91
  46. package/src/telegram-log-command.lib.mjs +7 -3
  47. package/src/telegram-overrides-validation.lib.mjs +73 -0
  48. package/src/telegram-terminal-watch-command.lib.mjs +9 -1
  49. package/src/working-session-summary.lib.mjs +1 -1
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { sanitizeForPublication } from './token-sanitization.lib.mjs';
5
+ import { findResidualCredentialBlock } from './log-sanitize-stream.lib.mjs';
6
+ import { sanitizeLogFileToFileBounded } from './log-sanitize-worker.lib.mjs';
5
7
 
6
8
  const sanitizePathSegment = (value, fallback) => {
7
9
  const raw = value === null || value === undefined || value === '' ? fallback : String(value);
@@ -94,13 +96,25 @@ const writePrivatePublicationFile = async (destinationPath, content) => {
94
96
  await fs.chmod(destinationPath, 0o600);
95
97
  };
96
98
 
99
+ // Issue #2189: `sanitizeLogFileToFileBounded` creates its destination exclusively
100
+ // (`wx`) so a pre-planted symlink cannot be followed. Collection may run more
101
+ // than once for the same session directory, so drop a previous artifact first —
102
+ // unlink-then-O_EXCL keeps the symlink guarantee that a plain truncate loses.
103
+ const sanitizeIntoPublicationFile = async ({ sourcePath, destinationPath, startByte = 0, endByte = null }) => {
104
+ await fs.rm(destinationPath, { force: true });
105
+ return sanitizeLogFileToFileBounded({ sourcePath, destPath: destinationPath, startByte, endByte });
106
+ };
107
+
97
108
  const copyIfExists = async ({ sourcePath, destinationPath }) => {
98
109
  if (!(await fileExists(sourcePath))) return false;
99
110
  // Raw local audit sources remain available to the operator but must not be
100
111
  // group/world-readable. Only the sanitized copy enters the repository.
101
112
  await fs.chmod(sourcePath, 0o600);
102
- const content = await fs.readFile(sourcePath, 'utf8');
103
- await writePrivatePublicationFile(destinationPath, content);
113
+ // Issue #2189: transcripts are as large as the run that produced them (the
114
+ // captured incident had a 134 MB one). Sanitize source → destination block by
115
+ // block instead of holding the file, its sanitized twin and the sanitizer's
116
+ // own working copy in the heap at once.
117
+ await sanitizeIntoPublicationFile({ sourcePath, destinationPath });
104
118
  return true;
105
119
  };
106
120
 
@@ -140,8 +154,7 @@ const copyLogSlice = async ({ logFile, destinationPath, logStartByte = 0 }) => {
140
154
  await writePrivatePublicationFile(destinationPath, '');
141
155
  return { logStartByte: start, logEndByte: stat.size };
142
156
  }
143
- const bytes = await fs.readFile(logFile);
144
- await writePrivatePublicationFile(destinationPath, bytes.subarray(start, stat.size).toString('utf8'));
157
+ await sanitizeIntoPublicationFile({ sourcePath: logFile, destinationPath, startByte: start, endByte: stat.size });
145
158
  return { logStartByte: start, logEndByte: stat.size };
146
159
  };
147
160
 
@@ -279,9 +292,11 @@ const verifyDevelopmentLogDirectory = async directoryPath => {
279
292
  if (!entry.isFile()) continue;
280
293
  const parentPath = entry.parentPath || entry.path;
281
294
  const filePath = path.join(parentPath, entry.name);
282
- const exactBytes = await fs.readFile(filePath, 'utf8');
283
- const rescanned = await sanitizeForPublication(exactBytes);
284
- if (rescanned !== exactBytes) {
295
+ // Issue #2189: rescan block by block. Reading each artifact back whole made
296
+ // the verification cost as much heap as the artifact — on top of the copy
297
+ // that had just been written.
298
+ const residual = await findResidualCredentialBlock(filePath);
299
+ if (residual) {
285
300
  throw new Error('Development-log publication rescan found residual credential material.');
286
301
  }
287
302
  await fs.chmod(filePath, 0o600);
@@ -27,6 +27,8 @@ import path from 'node:path';
27
27
  import { execFile } from 'node:child_process';
28
28
  import { promisify } from 'node:util';
29
29
 
30
+ import { DEFAULT_AGENT_SNAPSHOT_MIN_IDLE_MS, getAgentDataHome, reclaimAgentSnapshotStores } from './agent-snapshot-store.lib.mjs';
31
+
30
32
  const execFileAsync = promisify(execFile);
31
33
 
32
34
  /**
@@ -224,8 +226,12 @@ export const reclaimSolverWorkspaces = async ({ requiredMB = 0, tmpRoot = DEFAUL
224
226
  *
225
227
  * An unreadable `df` never blocks work: the guard is an optimisation over solve's own pre-flight
226
228
  * check, not a replacement for it.
229
+ *
230
+ * Issue #2186 added a second source of reclaimable space: orphaned
231
+ * `@link-assistant/agent` snapshot stores in the home directory, which no `/tmp`-scoped check
232
+ * could see. Pass `agentDataHome: null` to opt out.
227
233
  */
228
- export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove } = {}) => {
234
+ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove, agentDataHome = getAgentDataHome(), agentSnapshotMinIdleMs = DEFAULT_AGENT_SNAPSHOT_MIN_IDLE_MS } = {}) => {
229
235
  const startedAt = now();
230
236
  const reclaimed = [];
231
237
  let freeMB = await getFreeMB(tmpRoot);
@@ -239,6 +245,20 @@ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = D
239
245
  }
240
246
  await log(` 💾 Low disk space: ${freeMB}MB free, ${requiredMB}MB required — reclaiming idle solver workspaces before starting work`, { level: 'warning' });
241
247
  for (;;) {
248
+ // Issue #2186: orphaned agent snapshot stores are pure garbage — their
249
+ // worktree is gone, so nothing can be restored from them — while a solver
250
+ // workspace may still be wanted for debugging. Reclaim them first, and note
251
+ // that they live in the home directory, which every check here used to be
252
+ // blind to.
253
+ if (agentDataHome) {
254
+ const agentResult = await reclaimAgentSnapshotStores({ dataHome: agentDataHome, minIdleMs: agentSnapshotMinIdleMs, stopWhenFreeMB: requiredMB, getFreeMB: () => getFreeMB(tmpRoot), now, log, fileSystem, remove });
255
+ reclaimed.push(...agentResult.removed);
256
+ if (agentResult.freeMB !== null && agentResult.freeMB !== undefined) freeMB = agentResult.freeMB;
257
+ if (freeMB >= requiredMB) {
258
+ await log(` ✅ Disk space recovered: ${freeMB}MB free after reclaiming ${agentResult.removed.length} orphaned agent snapshot store(s)`);
259
+ return { ok: true, freeMB, reason: 'reclaimed', reclaimed, waitedMs: now() - startedAt };
260
+ }
261
+ }
242
262
  const result = await reclaimSolverWorkspaces({ requiredMB, tmpRoot, protectedPaths, minIdleMs, now, log, fileSystem, procRoot, getFreeMB, remove });
243
263
  reclaimed.push(...result.removed);
244
264
  if (result.freeMB !== null && result.freeMB !== undefined) freeMB = result.freeMB;
@@ -29,13 +29,17 @@ export const FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION = '0.336.0';
29
29
  * PR #2147 this is the *initial* pin only: once the container is running,
30
30
  * `src/formal-ai-updater.lib.mjs` replaces it with the newest published image
31
31
  * while no Formal AI task holds a lease. 0.339.0 restored `cargo install
32
- * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routes
33
- * command execution through the published command-stream component; the
34
- * memory-contract sources (`src/cli_memory.rs`, `src/server.rs`,
35
- * `src/shared_memory.rs`) are byte-identical to 0.337.0 and
36
- * `src/memory/upgrade.rs` only adds an explicit advisory-lock release.
32
+ * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routed
33
+ * command execution through the published command-stream component. 0.345.0 is
34
+ * the current release and is a safe bootstrap for the same reason 0.339.1 was:
35
+ * its Cargo.lock still carries no `openssl-sys`, so the builder stage keeps
36
+ * building on a stock `rust:slim` image, and the four memory-contract sources
37
+ * (`src/cli_memory.rs`, `src/server.rs`, `src/shared_memory.rs`,
38
+ * `src/memory/upgrade.rs`) are byte-identical to 0.339.1 — 0.340.0-0.345.0 only
39
+ * change reasoning data, benchmarks and unrelated handlers. Verified by
40
+ * diffing the published crates; see docs/case-studies/issue-2186.
37
41
  */
38
- export const FORMAL_AI_BOOTSTRAP_VERSION = '0.339.1';
42
+ export const FORMAL_AI_BOOTSTRAP_VERSION = '0.345.0';
39
43
 
40
44
  export const parseFormalAiVersion = stdout => {
41
45
  const line = String(stdout || '')
@@ -9,6 +9,8 @@ import { createInterface } from 'readline';
9
9
  import { log, cleanErrorMessage, getAbsoluteLogPath } from './lib.mjs';
10
10
  import { reportError, isSentryEnabled } from './sentry.lib.mjs';
11
11
  import { sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
12
+ import { sanitizeLogFileToFileBounded } from './log-sanitize-worker.lib.mjs';
13
+ import { readLogTailText } from './log-bounded-read.lib.mjs';
12
14
 
13
15
  if (typeof globalThis.use === 'undefined') {
14
16
  await ensureUseM();
@@ -107,8 +109,87 @@ const createSecretGist = async (logContent, filename) => {
107
109
  return null;
108
110
  };
109
111
 
112
+ /**
113
+ * Upload a log FILE as a secret gist without ever holding it in memory.
114
+ *
115
+ * Issue #2189: the error reporter runs when the process is already in trouble —
116
+ * frequently because it just exhausted its heap. Reading the log to sanitize it
117
+ * (`readFile` + `sanitizeForPublication` + write = three full copies) is the one
118
+ * thing that must not happen there.
119
+ *
120
+ * @param {string} logFilePath - Log to upload
121
+ * @param {string} filename - Name for the gist file
122
+ * @returns {Promise<string|null>} Gist URL, or null when the upload failed
123
+ */
124
+ const createSecretGistFromFile = async (logFilePath, filename) => {
125
+ const tempFile = `/tmp/${filename}`;
126
+ try {
127
+ await sanitizeLogFileToFileBounded({ sourcePath: logFilePath, destPath: tempFile });
128
+ const result = await $`gh gist create ${tempFile} --secret --desc "Error log for hive-mind"`;
129
+ if (result.exitCode === 0) {
130
+ return result.stdout.toString().trim();
131
+ }
132
+ } catch (error) {
133
+ reportError(error, {
134
+ context: 'create_secret_gist',
135
+ operation: 'gh_gist_create',
136
+ });
137
+ } finally {
138
+ await fs.unlink(tempFile).catch(() => {});
139
+ }
140
+ return null;
141
+ };
142
+
143
+ /**
144
+ * Format a log FILE for an issue body, choosing the attachment method from the
145
+ * file's size before reading any of it (issue #2189).
146
+ *
147
+ * Only the inline branch — by definition below GitHub's 60 kB issue-body limit —
148
+ * ever reads log content, and the truncated fallback reads a bounded tail.
149
+ *
150
+ * @param {string} logFilePath - Path to the log file
151
+ * @returns {Promise<{method: string, content: string}>}
152
+ */
153
+ export const formatLogFileForIssue = async logFilePath => {
154
+ const { size } = await fs.stat(logFilePath);
155
+
156
+ if (size < GITHUB_ISSUE_BODY_MAX_SIZE) {
157
+ const logContent = await fs.readFile(logFilePath, 'utf8');
158
+ return {
159
+ method: 'inline',
160
+ content: `\`\`\`\n${logContent}\n\`\`\``,
161
+ };
162
+ }
163
+
164
+ if (size < GITHUB_FILE_MAX_SIZE) {
165
+ return {
166
+ method: 'file',
167
+ content: `Log file is too large to include inline. Please see the attached log file.\n\nLog file path: \`${logFilePath}\``,
168
+ };
169
+ }
170
+
171
+ const gistUrl = await createSecretGistFromFile(logFilePath, `hive-mind-error-${Date.now()}.log`);
172
+ if (gistUrl) {
173
+ return {
174
+ method: 'gist',
175
+ content: `Log file is too large for inline attachment.\n\n📄 View full log: ${gistUrl}`,
176
+ };
177
+ }
178
+
179
+ const tail = await readLogTailText(logFilePath, { maxBytes: 5000 });
180
+ return {
181
+ method: 'truncated',
182
+ content: `Log file is too large. Showing last 5000 characters:\n\n\`\`\`\n${tail}\n\`\`\``,
183
+ };
184
+ };
185
+
110
186
  /**
111
187
  * Format log content for issue body
188
+ *
189
+ * Prefer {@link formatLogFileForIssue} when the log is a file on disk: this
190
+ * variant needs the whole log as a string, which is exactly what issue #2189
191
+ * removed from the publication path.
192
+ *
112
193
  * @param {string} logContent - Log file content
113
194
  * @param {string} logFilePath - Path to log file
114
195
  * @returns {Promise<Object>} Object with formatted content and attachment method
@@ -202,8 +283,9 @@ export const createIssueForError = async options => {
202
283
 
203
284
  if (logFile) {
204
285
  try {
205
- const logContent = await fs.readFile(logFile, 'utf8');
206
- const { method, content } = await formatLogForIssue(logContent, logFile);
286
+ // Issue #2189: pick the attachment method from the file size first; a
287
+ // log too large for the issue body is never read into memory here.
288
+ const { method, content } = await formatLogFileForIssue(logFile);
207
289
 
208
290
  issueBody += `### Log File\n\n${content}\n\n`;
209
291
  await log(`📄 Log attached via: ${method}`);