@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.
- package/CHANGELOG.md +125 -0
- package/README.hi.md +12 -0
- package/README.md +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +24 -12
- package/package.json +24 -17
- package/src/agent-snapshot-store.lib.mjs +252 -0
- package/src/agent.lib.mjs +25 -25
- package/src/agent.version-gates.lib.mjs +73 -0
- package/src/bot-lifecycle.lib.mjs +63 -5
- package/src/child-exit.lib.mjs +53 -1
- package/src/claude.session-tokens.lib.mjs +10 -8
- package/src/claude.session-transcript-repair.lib.mjs +65 -34
- package/src/cleanup.mjs +57 -3
- package/src/codex.lib.mjs +11 -1
- package/src/development-log.lib.mjs +22 -7
- package/src/disk-guard.lib.mjs +21 -1
- package/src/formal-ai-version.lib.mjs +10 -6
- package/src/github-error-reporter.lib.mjs +84 -2
- package/src/github.lib.mjs +212 -152
- package/src/instrument.mjs +12 -14
- package/src/instrument.sanitize.lib.mjs +52 -0
- package/src/isolation-runner.lib.mjs +56 -33
- package/src/isolation-runner.parsers.lib.mjs +29 -3
- package/src/isolation-runner.resume.lib.mjs +263 -0
- package/src/log-bounded-read.lib.mjs +411 -0
- package/src/log-sanitize-stream.lib.mjs +267 -0
- package/src/log-sanitize-worker-entry.mjs +31 -0
- package/src/log-sanitize-worker.lib.mjs +186 -0
- package/src/log-upload.lib.mjs +16 -4
- package/src/pull-request-changes.lib.mjs +1 -1
- package/src/session-completion-state.lib.mjs +124 -0
- package/src/session-kill-diagnostics.lib.mjs +117 -12
- package/src/session-kill-policy.lib.mjs +18 -7
- package/src/session-kill-resume.in-place.lib.mjs +136 -0
- package/src/session-kill-resume.lib.mjs +48 -18
- package/src/session-monitor.kill-sections.lib.mjs +8 -0
- package/src/session-monitor.lib.mjs +132 -9
- package/src/session-store.lib.mjs +15 -1
- package/src/solve.clone-errors.lib.mjs +86 -0
- package/src/solve.config.lib.mjs +5 -2
- package/src/solve.repository.lib.mjs +36 -63
- package/src/solve.resource-diagnostics.lib.mjs +105 -5
- package/src/start-command-cli.lib.mjs +60 -0
- package/src/telegram-bot.mjs +31 -91
- package/src/telegram-log-command.lib.mjs +7 -3
- package/src/telegram-overrides-validation.lib.mjs +73 -0
- package/src/telegram-terminal-watch-command.lib.mjs +9 -1
- 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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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);
|
package/src/disk-guard.lib.mjs
CHANGED
|
@@ -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
|
|
33
|
-
* command execution through the published command-stream component
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
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.
|
|
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
|
-
|
|
206
|
-
|
|
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}`);
|