@link-assistant/hive-mind 2.8.8 → 2.8.10
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 +44 -0
- package/package.json +1 -1
- package/src/cleanup.mjs +3 -1
- package/src/development-log.finalize.lib.mjs +100 -6
- package/src/development-log.lib.mjs +43 -5
- package/src/error-formatting.lib.mjs +54 -0
- package/src/exit-handler.lib.mjs +9 -0
- package/src/fix.mjs +5 -2
- package/src/solve.restart-shared.lib.mjs +12 -0
- package/src/use-m-bootstrap.lib.mjs +13 -2
- package/src/use-with-retry.lib.mjs +122 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.8.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- b93ed64: fix(2092): make every `use-m` call site self-healing
|
|
8
|
+
|
|
9
|
+
`/fix --ci-cd` crashed on `await use('command-stream')` — once on a truncated
|
|
10
|
+
global install, once on a failed `npm install -g`. The existing corrupt-install
|
|
11
|
+
recovery was wired into 3 of 100 `use(...)` call sites, so the ~40 top-level
|
|
12
|
+
`command-stream` loads were unprotected.
|
|
13
|
+
|
|
14
|
+
- `ensureUseM()` now returns a retry-wrapped `use`, so every call site inherits
|
|
15
|
+
the recovery (idempotent, no per-call-site edits).
|
|
16
|
+
- New retry mode for `Failed to install <pkg> globally into '<dir>'`, with
|
|
17
|
+
exponential backoff.
|
|
18
|
+
- Cleanup deletes the whole `<pkg>-v-<version>` alias directory instead of the
|
|
19
|
+
entry file's parent directory.
|
|
20
|
+
- Retries bust Node's ESM cache, which otherwise replays the original
|
|
21
|
+
`SyntaxError` even after a healthy reinstall.
|
|
22
|
+
- `formatFatalError` restores cause chains (and stacks under `HIVE_MIND_VERBOSE`)
|
|
23
|
+
in `fix.mjs`/`cleanup.mjs`; `HIVE_MIND_USE_M_DEBUG=1` logs each loader attempt.
|
|
24
|
+
|
|
25
|
+
## 2.8.9
|
|
26
|
+
|
|
27
|
+
### Patch Changes
|
|
28
|
+
|
|
29
|
+
- fd1789e: fix(development-log): collect a development log for every working session (#2090)
|
|
30
|
+
|
|
31
|
+
A run with `--auto-restart-until-mergeable` (implied by `--auto-merge`) starts a
|
|
32
|
+
new tool session with its own session UUID per restart iteration, but only the
|
|
33
|
+
first session ever reached the pull request:
|
|
34
|
+
`createDevelopmentLogFinalizer` memoized a single collection per process, no
|
|
35
|
+
restart path invoked the finalizer again, and several exit paths (usage limit,
|
|
36
|
+
tool failure, graceful shutdown, auto-continue) skipped finalization entirely.
|
|
37
|
+
The single collected `solve.log` was also truncated at collection time and was
|
|
38
|
+
committed twice, as a byte-identical duplicate under `<tool>-<sessionId>.log`.
|
|
39
|
+
|
|
40
|
+
The finalizer is now memoized per session id, every restart iteration finalizes
|
|
41
|
+
its own session at the shared `executeToolIteration` chokepoint, `safeExit`
|
|
42
|
+
forces a final collection on every exit path, and each session directory stores
|
|
43
|
+
only its own byte range of the process log (`metadata.json` schema version 3,
|
|
44
|
+
`artifacts.solveLogRange`) so the union of the sessions is the complete log
|
|
45
|
+
without duplication.
|
|
46
|
+
|
|
3
47
|
## 2.8.8
|
|
4
48
|
|
|
5
49
|
### Patch Changes
|
package/package.json
CHANGED
package/src/cleanup.mjs
CHANGED
|
@@ -438,6 +438,8 @@ async function main() {
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
main().catch(async error => {
|
|
441
|
-
|
|
441
|
+
// Issue #2092: keep the cause chain so use-m load failures stay diagnosable.
|
|
442
|
+
const { formatFatalError } = await import('./error-formatting.lib.mjs');
|
|
443
|
+
await log(formatFatalError(error), { level: 'error' });
|
|
442
444
|
process.exit(1);
|
|
443
445
|
});
|
|
@@ -1,11 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Build a
|
|
3
|
-
*
|
|
2
|
+
* Build a per-session finalizer so every completion path can preserve a
|
|
3
|
+
* development log without creating duplicate commits.
|
|
4
|
+
*
|
|
5
|
+
* Issue #1596 introduced a once-only finalizer: the first call collected the
|
|
6
|
+
* artifacts and every later call reused the memoized promise. Issue #2090
|
|
7
|
+
* showed that this silently drops every session after the first one — a run
|
|
8
|
+
* with `--auto-restart-until-mergeable` starts a brand new tool session (a new
|
|
9
|
+
* session UUID) per restart iteration, but only the very first session ever
|
|
10
|
+
* reached the repository.
|
|
11
|
+
*
|
|
12
|
+
* The finalizer is therefore memoized *per session id* instead of per process:
|
|
13
|
+
*
|
|
14
|
+
* - the same session id is collected only once (no duplicate commits),
|
|
15
|
+
* - a different session id is collected again into its own `sessions/<uuid>/`
|
|
16
|
+
* directory,
|
|
17
|
+
* - each collection copies only the slice of the solve log that was produced
|
|
18
|
+
* since the previous collection, so the union of all session directories is
|
|
19
|
+
* the complete process log without duplicating megabytes per session.
|
|
4
20
|
*/
|
|
5
|
-
export const createDevelopmentLogFinalizer = ({ collect, getParams }) => {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
21
|
+
export const createDevelopmentLogFinalizer = ({ collect, getParams, register = true }) => {
|
|
22
|
+
// sessionKey -> promise of the collection result (dedupe per session).
|
|
23
|
+
const collections = new Map();
|
|
24
|
+
// sessionKey -> { startByte, sessionId } of an already collected session.
|
|
25
|
+
const sessionStartBytes = new Map();
|
|
26
|
+
// Byte offset in the solve log right after the last collected slice.
|
|
27
|
+
let nextLogStartByte = 0;
|
|
28
|
+
// Key of the session collected most recently, so a forced finalize at exit
|
|
29
|
+
// extends *that* session's log slice instead of re-collecting the first one.
|
|
30
|
+
let lastSessionKey = null;
|
|
31
|
+
// Serializes collections so their log slices never interleave.
|
|
32
|
+
let queue = Promise.resolve();
|
|
33
|
+
|
|
34
|
+
const toKey = sessionId => (sessionId ? String(sessionId) : '__no-session__');
|
|
35
|
+
|
|
36
|
+
const finalize = (options = {}) => {
|
|
37
|
+
const params = { ...getParams() };
|
|
38
|
+
if (options.sessionId !== undefined && options.sessionId !== null) params.sessionId = options.sessionId;
|
|
39
|
+
|
|
40
|
+
let sessionKey = toKey(params.sessionId);
|
|
41
|
+
if (options.force && options.sessionId === undefined && lastSessionKey) {
|
|
42
|
+
// Exit-time collection: the log tail belongs to the session collected
|
|
43
|
+
// most recently (which is a restart-iteration session, not the first one
|
|
44
|
+
// still referenced by the caller's `sessionId` variable).
|
|
45
|
+
sessionKey = lastSessionKey;
|
|
46
|
+
params.sessionId = sessionStartBytes.get(sessionKey)?.sessionId ?? params.sessionId;
|
|
47
|
+
}
|
|
48
|
+
const alreadyCollected = collections.has(sessionKey);
|
|
49
|
+
|
|
50
|
+
// Re-collecting the same session is only useful when the caller explicitly
|
|
51
|
+
// asks for it (process exit, to capture the log tail produced after the
|
|
52
|
+
// session finished). Otherwise reuse the memoized result.
|
|
53
|
+
if (alreadyCollected && !options.force) return collections.get(sessionKey);
|
|
54
|
+
|
|
55
|
+
lastSessionKey = sessionKey;
|
|
56
|
+
|
|
57
|
+
// Collections are serialized: each one commits and pushes, and the log
|
|
58
|
+
// slice boundaries only make sense when resolved sequentially.
|
|
59
|
+
const resultPromise = queue
|
|
60
|
+
.catch(() => {})
|
|
61
|
+
.then(() => {
|
|
62
|
+
const known = sessionStartBytes.get(sessionKey);
|
|
63
|
+
const logStartByte = known ? known.startByte : nextLogStartByte;
|
|
64
|
+
sessionStartBytes.set(sessionKey, { startByte: logStartByte, sessionId: params.sessionId ?? null });
|
|
65
|
+
return collect({ ...params, logStartByte });
|
|
66
|
+
})
|
|
67
|
+
.then(result => {
|
|
68
|
+
const endByte = result?.logEndByte;
|
|
69
|
+
if (typeof endByte === 'number' && endByte > nextLogStartByte) nextLogStartByte = endByte;
|
|
70
|
+
return result;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
queue = resultPromise.catch(() => {});
|
|
74
|
+
collections.set(sessionKey, resultPromise);
|
|
9
75
|
return resultPromise;
|
|
10
76
|
};
|
|
77
|
+
|
|
78
|
+
finalize.getCollectedSessionKeys = () => [...collections.keys()];
|
|
79
|
+
// Publish the finalizer so restart iterations (watch mode,
|
|
80
|
+
// auto-restart-until-mergeable, keep-working, escalation, auto-ensure) and
|
|
81
|
+
// every exit path can collect the session they just finished.
|
|
82
|
+
if (register) setActiveDevelopmentLogFinalizer(finalize);
|
|
83
|
+
return finalize;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Module-level registry so restart iterations deep in the call tree (watch
|
|
87
|
+
// mode, auto-restart-until-mergeable, keep-working, escalation, auto-ensure)
|
|
88
|
+
// can finalize the development log of the session they just finished without
|
|
89
|
+
// threading the finalizer through every call signature.
|
|
90
|
+
let activeFinalizer = null;
|
|
91
|
+
|
|
92
|
+
export const setActiveDevelopmentLogFinalizer = finalizer => {
|
|
93
|
+
activeFinalizer = typeof finalizer === 'function' ? finalizer : null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const getActiveDevelopmentLogFinalizer = () => activeFinalizer;
|
|
97
|
+
|
|
98
|
+
export const finalizeActiveDevelopmentLog = async (options = {}) => {
|
|
99
|
+
if (!activeFinalizer) return { skipped: 'no-active-finalizer' };
|
|
100
|
+
try {
|
|
101
|
+
return await activeFinalizer(options);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
return { skipped: 'error', error };
|
|
104
|
+
}
|
|
11
105
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
4
6
|
|
|
5
7
|
const sanitizePathSegment = (value, fallback) => {
|
|
6
8
|
const raw = value === null || value === undefined || value === '' ? fallback : String(value);
|
|
@@ -113,6 +115,23 @@ const findCodexSessionFile = async ({ sessionId, homeDir }) => {
|
|
|
113
115
|
}
|
|
114
116
|
};
|
|
115
117
|
|
|
118
|
+
// Copy a byte range of the solve log into the session directory.
|
|
119
|
+
// Issue #2090: each session stores only the slice of the process log that was
|
|
120
|
+
// produced while that session was running, so the union of all session
|
|
121
|
+
// directories is the complete log instead of N truncated copies of the same
|
|
122
|
+
// prefix. Returns the byte offset right after the copied slice.
|
|
123
|
+
const copyLogSlice = async ({ logFile, destinationPath, logStartByte = 0 }) => {
|
|
124
|
+
const stat = await fs.stat(logFile);
|
|
125
|
+
// The log was rotated/truncated since the previous collection: copy it whole.
|
|
126
|
+
const start = Number.isFinite(logStartByte) && logStartByte > 0 && logStartByte <= stat.size ? logStartByte : 0;
|
|
127
|
+
if (stat.size === 0 || start >= stat.size) {
|
|
128
|
+
await fs.writeFile(destinationPath, '');
|
|
129
|
+
return { logStartByte: start, logEndByte: stat.size };
|
|
130
|
+
}
|
|
131
|
+
await pipeline(createReadStream(logFile, { start, end: stat.size - 1 }), createWriteStream(destinationPath));
|
|
132
|
+
return { logStartByte: start, logEndByte: stat.size };
|
|
133
|
+
};
|
|
134
|
+
|
|
116
135
|
const copyKnownSessionFiles = async ({ repositoryPath, sessionRelativeDirectory, logFile, sessionId, tool, homeDir }) => {
|
|
117
136
|
if (!sessionId) return [];
|
|
118
137
|
|
|
@@ -149,9 +168,16 @@ const copyKnownSessionFiles = async ({ repositoryPath, sessionRelativeDirectory,
|
|
|
149
168
|
|
|
150
169
|
const copied = [];
|
|
151
170
|
const seenSources = new Set();
|
|
171
|
+
// Issue #2090: when the tool renamed the running solve log to
|
|
172
|
+
// `<sessionId>.log`, this candidate resolves to the very log file that is
|
|
173
|
+
// already copied as solve.log — copying it again duplicated megabytes per
|
|
174
|
+
// session (PR link-assistant/formal-ai#809 stored two byte-identical 7 MB
|
|
175
|
+
// files). Skip it instead.
|
|
176
|
+
const resolvedLogFile = logFile ? path.resolve(logFile) : null;
|
|
152
177
|
for (const candidate of candidates) {
|
|
153
178
|
if (!candidate.sourcePath || seenSources.has(candidate.sourcePath)) continue;
|
|
154
179
|
seenSources.add(candidate.sourcePath);
|
|
180
|
+
if (resolvedLogFile && path.resolve(candidate.sourcePath) === resolvedLogFile) continue;
|
|
155
181
|
|
|
156
182
|
const relativePath = `${sessionRelativeDirectory}/${safeFileName(candidate.destinationName)}`;
|
|
157
183
|
const copiedPath = path.join(sessionDirectory, safeFileName(candidate.destinationName));
|
|
@@ -163,7 +189,7 @@ const copyKnownSessionFiles = async ({ repositoryPath, sessionRelativeDirectory,
|
|
|
163
189
|
return copied;
|
|
164
190
|
};
|
|
165
191
|
|
|
166
|
-
export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, now = new Date(), homeDir = os.homedir() }) => {
|
|
192
|
+
export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, now = new Date(), homeDir = os.homedir() }) => {
|
|
167
193
|
if (!repositoryPath) {
|
|
168
194
|
throw new Error('repositoryPath is required to write development-log artifacts');
|
|
169
195
|
}
|
|
@@ -179,9 +205,14 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
|
|
|
179
205
|
await fs.mkdir(sessionDirectory, { recursive: true });
|
|
180
206
|
|
|
181
207
|
let copiedLogRelativePath = null;
|
|
208
|
+
let logSlice = { logStartByte: 0, logEndByte: 0 };
|
|
182
209
|
if (logFile) {
|
|
183
210
|
copiedLogRelativePath = `${sessionRelativeDirectory}/solve.log`;
|
|
184
|
-
await
|
|
211
|
+
logSlice = await copyLogSlice({
|
|
212
|
+
logFile,
|
|
213
|
+
destinationPath: path.join(repositoryPath, copiedLogRelativePath),
|
|
214
|
+
logStartByte,
|
|
215
|
+
});
|
|
185
216
|
}
|
|
186
217
|
|
|
187
218
|
const sessionFiles = await copyKnownSessionFiles({
|
|
@@ -195,7 +226,9 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
|
|
|
195
226
|
|
|
196
227
|
const metadataRelativePath = `${sessionRelativeDirectory}/metadata.json`;
|
|
197
228
|
const metadata = {
|
|
198
|
-
|
|
229
|
+
// v3 (issue #2090): one directory per tool session, `solve.log` holds only
|
|
230
|
+
// this session's slice of the process log (see solveLogRange).
|
|
231
|
+
schemaVersion: 3,
|
|
199
232
|
collectedAt: now.toISOString(),
|
|
200
233
|
issueNumber: issueNumber ?? null,
|
|
201
234
|
prNumber: prNumber ?? null,
|
|
@@ -207,6 +240,7 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
|
|
|
207
240
|
caseStudyDirectory,
|
|
208
241
|
artifacts: {
|
|
209
242
|
solveLog: copiedLogRelativePath ? addDotSlash(toPosixPath(copiedLogRelativePath)) : null,
|
|
243
|
+
solveLogRange: copiedLogRelativePath ? { startByte: logSlice.logStartByte, endByte: logSlice.logEndByte } : null,
|
|
210
244
|
sessionFiles,
|
|
211
245
|
},
|
|
212
246
|
};
|
|
@@ -221,12 +255,14 @@ export const writeDevelopmentLogArtifacts = async ({ repositoryPath, logFile, is
|
|
|
221
255
|
copiedLogRelativePath: copiedLogRelativePath ? toPosixPath(copiedLogRelativePath) : null,
|
|
222
256
|
metadataRelativePath: toPosixPath(metadataRelativePath),
|
|
223
257
|
sessionFiles,
|
|
258
|
+
logStartByte: logSlice.logStartByte,
|
|
259
|
+
logEndByte: logSlice.logEndByte,
|
|
224
260
|
};
|
|
225
261
|
};
|
|
226
262
|
|
|
227
263
|
const getCommandOutput = result => (result?.stderr?.toString?.() || result?.stdout?.toString?.() || '').trim();
|
|
228
264
|
|
|
229
|
-
export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, $, log }) => {
|
|
265
|
+
export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, repositoryPath, logFile, issueNumber, prNumber, tool, sessionId, branchName, rawCommand, logStartByte = 0, $, log }) => {
|
|
230
266
|
if (!enabled) {
|
|
231
267
|
return { skipped: 'disabled' };
|
|
232
268
|
}
|
|
@@ -237,7 +273,7 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
237
273
|
}
|
|
238
274
|
|
|
239
275
|
// Issue #2048: verbose trace so the commit timing (relative to PR readiness signals) is diagnosable from logs.
|
|
240
|
-
await log?.(`🔍 Development log finalize: issue #${issueNumber ?? '?'}, PR #${prNumber ?? 'pending'}, branch ${branchName ?? 'none'}, session ${sessionId ?? 'none'}`, { verbose: true });
|
|
276
|
+
await log?.(`🔍 Development log finalize: issue #${issueNumber ?? '?'}, PR #${prNumber ?? 'pending'}, branch ${branchName ?? 'none'}, session ${sessionId ?? 'none'}, log slice from byte ${logStartByte}`, { verbose: true });
|
|
241
277
|
|
|
242
278
|
try {
|
|
243
279
|
const artifacts = await writeDevelopmentLogArtifacts({
|
|
@@ -249,8 +285,10 @@ export const collectAndCommitDevelopmentLogArtifacts = async ({ enabled, reposit
|
|
|
249
285
|
sessionId,
|
|
250
286
|
branchName,
|
|
251
287
|
rawCommand,
|
|
288
|
+
logStartByte,
|
|
252
289
|
});
|
|
253
290
|
|
|
291
|
+
await log?.(`🧾 Development log artifacts written to ${artifacts.sessionRelativeDirectory} (log bytes ${artifacts.logStartByte}-${artifacts.logEndByte})`, { verbose: true });
|
|
254
292
|
await log?.(`🧾 Development log artifacts written to ${artifacts.developmentLogDirectory}`);
|
|
255
293
|
|
|
256
294
|
if (!$) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared fatal-error formatting (issue #2092).
|
|
5
|
+
*
|
|
6
|
+
* The failing `/fix --ci-cd` runs printed exactly one line:
|
|
7
|
+
*
|
|
8
|
+
* ❌ Failed to import module from '/home/box/.../command-stream-v-latest/src/$.mjs'.
|
|
9
|
+
*
|
|
10
|
+
* because the entry points did `console.error(\`❌ ${error.message}\`)`. Everything
|
|
11
|
+
* that would have identified the problem — the `SyntaxError` in `error.cause`,
|
|
12
|
+
* the stack showing which module triggered the load — was discarded, so the
|
|
13
|
+
* first investigation had to guess. This helper keeps the one-line summary but
|
|
14
|
+
* appends the cause chain, and the full stacks when verbose output is enabled.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {unknown} error - the thrown value.
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {boolean} [options.verbose] - include stacks; defaults to the
|
|
23
|
+
* `HIVE_MIND_VERBOSE` / `VERBOSE` environment variables.
|
|
24
|
+
* @returns {string} a multi-line, human-readable rendering of the error.
|
|
25
|
+
*/
|
|
26
|
+
export const formatFatalError = (error, options = {}) => {
|
|
27
|
+
const verbose = options.verbose ?? Boolean(process.env.HIVE_MIND_VERBOSE || process.env.VERBOSE);
|
|
28
|
+
const lines = [`❌ ${describe(error)}`];
|
|
29
|
+
|
|
30
|
+
let current = error?.cause;
|
|
31
|
+
for (let depth = 0; current && depth < MAX_CAUSE_DEPTH; depth++) {
|
|
32
|
+
lines.push(` Caused by: ${describe(current)}`);
|
|
33
|
+
if (verbose && typeof current?.stack === 'string') lines.push(indent(current.stack));
|
|
34
|
+
current = current?.cause;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (verbose && typeof error?.stack === 'string') lines.push(indent(error.stack));
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const describe = value => {
|
|
42
|
+
if (value === null || value === undefined) return String(value);
|
|
43
|
+
if (typeof value !== 'object') return String(value);
|
|
44
|
+
const name = value.name || value.constructor?.name || 'Error';
|
|
45
|
+
const message = typeof value.message === 'string' && value.message ? value.message : JSON.stringify(value);
|
|
46
|
+
const code = value.code ? ` (code: ${value.code})` : '';
|
|
47
|
+
return `${name}: ${message}${code}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const indent = text =>
|
|
51
|
+
String(text)
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map(line => ` ${line}`)
|
|
54
|
+
.join('\n');
|
package/src/exit-handler.lib.mjs
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
// Issue #1823: working-session guard for --do-not-shutdown-in-the-middle-of-working-session.
|
|
10
10
|
// Static import is safe: working-session.lib.mjs has no heavy deps and does NOT import this module.
|
|
11
11
|
import { isFlagEnabled as isWorkingSessionFlagEnabled, isWorkingSessionActive, requestShutdown as requestWorkingSessionShutdown, forceKillActiveChildren as forceKillWorkingSessionChildren } from './working-session.lib.mjs';
|
|
12
|
+
// Issue #2090: preserve the development log on every exit path (usage limit
|
|
13
|
+
// reached, tool failure, repository setup failure, graceful shutdown,
|
|
14
|
+
// auto-continue hand-off). No-op unless solve registered a finalizer, so hive
|
|
15
|
+
// and other consumers of this module are unaffected.
|
|
16
|
+
import { finalizeActiveDevelopmentLog } from './development-log.finalize.lib.mjs';
|
|
12
17
|
|
|
13
18
|
// Lazy-load Sentry to avoid keeping the event loop alive when not needed
|
|
14
19
|
let Sentry = null;
|
|
@@ -241,6 +246,10 @@ export const logActiveHandles = async (log = null) => {
|
|
|
241
246
|
export const safeExit = async (code = 0, reason = 'Process completed', { skipPreExit = false, failureActionSection = null } = {}) => {
|
|
242
247
|
await showExitMessage(reason, code);
|
|
243
248
|
|
|
249
|
+
// Issue #2090: collect the working session that is still uncollected (and the
|
|
250
|
+
// log tail produced after it) before the process goes away.
|
|
251
|
+
await finalizeActiveDevelopmentLog({ force: true });
|
|
252
|
+
|
|
244
253
|
if (!skipPreExit && code !== 0 && preExitFunction && !preExitHandlerRan) {
|
|
245
254
|
preExitHandlerRan = true;
|
|
246
255
|
try {
|
package/src/fix.mjs
CHANGED
|
@@ -236,7 +236,10 @@ async function main() {
|
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
main().catch(error => {
|
|
240
|
-
|
|
239
|
+
main().catch(async error => {
|
|
240
|
+
// Issue #2092: printing only error.message hid the SyntaxError cause of the
|
|
241
|
+
// use-m load failure, leaving the run log undiagnosable.
|
|
242
|
+
const { formatFatalError } = await import('./error-formatting.lib.mjs');
|
|
243
|
+
console.error(formatFatalError(error));
|
|
241
244
|
process.exit(1);
|
|
242
245
|
});
|
|
@@ -476,6 +476,18 @@ export const executeToolIteration = async params => {
|
|
|
476
476
|
diskPath: '/',
|
|
477
477
|
label: 'after AI restart iteration',
|
|
478
478
|
});
|
|
479
|
+
|
|
480
|
+
// Issue #2090: every restart iteration starts a brand new tool session with
|
|
481
|
+
// its own session UUID. Collect its development log here — this is the single
|
|
482
|
+
// chokepoint shared by watch mode, auto-restart-until-mergeable,
|
|
483
|
+
// keep-working, escalation and auto-ensure — otherwise only the very first
|
|
484
|
+
// session of the process ever reached the pull request.
|
|
485
|
+
// When the tool did not report a session id there is nothing to key a new
|
|
486
|
+
// directory on, so extend the previously collected session instead of losing
|
|
487
|
+
// this iteration's part of the log.
|
|
488
|
+
const { finalizeActiveDevelopmentLog } = await import('./development-log.finalize.lib.mjs');
|
|
489
|
+
await (toolResult?.sessionId ? finalizeActiveDevelopmentLog({ sessionId: toolResult.sessionId }) : finalizeActiveDevelopmentLog({ force: true }));
|
|
490
|
+
|
|
479
491
|
return toolResult;
|
|
480
492
|
};
|
|
481
493
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { wrapUseWithRetry } from './use-with-retry.lib.mjs';
|
|
4
|
+
|
|
3
5
|
export const USE_M_BOOTSTRAP_URL = 'https://unpkg.com/use-m/use.js';
|
|
4
6
|
export const USE_M_BOOTSTRAP_FALLBACK_URL = 'https://unpkg.com/use-m@8.13.8/use.js';
|
|
5
7
|
|
|
@@ -41,12 +43,21 @@ const fallbackFetchUseMCode = () => fetchUseMCodeFromUrl(USE_M_BOOTSTRAP_FALLBAC
|
|
|
41
43
|
export const ensureUseM = async (options = {}) => {
|
|
42
44
|
const { fetchUseMCode = defaultFetchUseMCode, log = null } = options;
|
|
43
45
|
if (typeof globalThis.use === 'undefined') {
|
|
46
|
+
let rawUse;
|
|
44
47
|
try {
|
|
45
|
-
|
|
48
|
+
rawUse = (await eval(await fetchUseMCode())).use;
|
|
46
49
|
} catch (error) {
|
|
47
50
|
if (typeof log === 'function') log(` use-m latest bootstrap failed (${error.message}); trying ${USE_M_BOOTSTRAP_FALLBACK_URL}`);
|
|
48
|
-
|
|
51
|
+
rawUse = (await eval(await fallbackFetchUseMCode())).use;
|
|
49
52
|
}
|
|
53
|
+
// Issue #2092: a truncated global `npm install -g <pkg>` makes use-m throw
|
|
54
|
+
// `Failed to import module from '<...>/command-stream-v-latest/src/$.mjs'.`
|
|
55
|
+
// Only a few call sites used useWithRetry explicitly; wrapping here means
|
|
56
|
+
// every `await use(...)` in the codebase recovers by deleting the corrupt
|
|
57
|
+
// install directory and re-fetching.
|
|
58
|
+
globalThis.use = wrapUseWithRetry(rawUse);
|
|
59
|
+
} else {
|
|
60
|
+
globalThis.use = wrapUseWithRetry(globalThis.use);
|
|
50
61
|
}
|
|
51
62
|
return globalThis.use;
|
|
52
63
|
};
|
|
@@ -30,20 +30,57 @@
|
|
|
30
30
|
* @param {number} [options.attempts=3] - total attempts including the first try.
|
|
31
31
|
* @param {(path: string) => Promise<void>} [options.cleanup] - injectable cleanup
|
|
32
32
|
* for the corrupted install directory (defaults to recursive `rm`).
|
|
33
|
+
* @param {(ms: number) => Promise<void>} [options.sleep] - injectable backoff used
|
|
34
|
+
* between attempts when the global `npm install -g` itself failed.
|
|
35
|
+
* @param {number} [options.backoffMs=1000] - base backoff, doubled per attempt.
|
|
36
|
+
* @param {(message: string) => void} [options.log] - diagnostics sink; defaults to
|
|
37
|
+
* `console.error` when `HIVE_MIND_USE_M_DEBUG` is set, otherwise silent.
|
|
33
38
|
* @returns {Promise<unknown>} the module returned by use-m.
|
|
34
39
|
*/
|
|
35
40
|
export const useWithRetry = async (use, specifier, options = {}) => {
|
|
36
41
|
const attempts = options.attempts ?? 3;
|
|
37
42
|
const cleanup = options.cleanup ?? defaultCleanup;
|
|
43
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
44
|
+
const backoffMs = options.backoffMs ?? 1000;
|
|
45
|
+
const log = options.log ?? defaultLog;
|
|
46
|
+
const importModule = options.importModule ?? defaultImport;
|
|
47
|
+
const extraArgs = options.args ?? [];
|
|
38
48
|
let lastError;
|
|
49
|
+
let cleanedImportPath = null;
|
|
39
50
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
40
51
|
try {
|
|
41
|
-
return await use(specifier);
|
|
52
|
+
return await use(specifier, ...extraArgs);
|
|
42
53
|
} catch (error) {
|
|
43
54
|
lastError = error;
|
|
44
|
-
|
|
55
|
+
// Node's ESM loader caches *failed* module evaluations by resolved URL.
|
|
56
|
+
// Once `<alias>/src/$.mjs` has thrown a SyntaxError, re-importing the very
|
|
57
|
+
// same path in this process replays that error even after the file on disk
|
|
58
|
+
// has been replaced by a healthy reinstall (verified against use-m@8.14.2 —
|
|
59
|
+
// see docs/case-studies/issue-2092). Deleting and reinstalling is therefore
|
|
60
|
+
// necessary but not sufficient: the retry must import through a
|
|
61
|
+
// cache-busting URL, which use-m has no way to do from the inside.
|
|
62
|
+
if (cleanedImportPath && extractCorruptedFilePath(error) === cleanedImportPath) {
|
|
63
|
+
try {
|
|
64
|
+
const recovered = await importModule(cleanedImportPath, attempt);
|
|
65
|
+
log(`use('${specifier}') recovered via a cache-busted import of ${cleanedImportPath}`);
|
|
66
|
+
return recovered;
|
|
67
|
+
} catch (reimportError) {
|
|
68
|
+
log(`cache-busted import of ${cleanedImportPath} also failed: ${reimportError?.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const retryable = isCorruptInstallError(error) || isTransientInstallError(error);
|
|
72
|
+
if (attempt === attempts || !retryable) {
|
|
73
|
+
log(`use('${specifier}') failed on attempt ${attempt}/${attempts} and will not be retried: ${error?.message}`);
|
|
45
74
|
throw error;
|
|
46
75
|
}
|
|
76
|
+
log(`use('${specifier}') failed on attempt ${attempt}/${attempts}: ${error?.message} — retrying`);
|
|
77
|
+
// Mode 4 (issue #2092): `npm install -g` itself failed (network blip,
|
|
78
|
+
// registry 5xx, DinD DNS not up yet). There is nothing to delete; just
|
|
79
|
+
// back off and let npm try again.
|
|
80
|
+
if (isTransientInstallError(error)) {
|
|
81
|
+
await sleep(backoffMs * 2 ** (attempt - 1));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
47
84
|
const corruptedPath = extractCorruptedFilePath(error);
|
|
48
85
|
if (corruptedPath) {
|
|
49
86
|
try {
|
|
@@ -53,9 +90,10 @@ export const useWithRetry = async (use, specifier, options = {}) => {
|
|
|
53
90
|
// * "Failed to resolve the path to 'pkg' from '<dir>'" — corruptedPath
|
|
54
91
|
// is the alias dir itself (e.g. /.../links-notation-v-latest).
|
|
55
92
|
// For files, walk up to the alias dir; otherwise remove the dir as-is.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
await cleanup(resolveAliasDir(corruptedPath));
|
|
94
|
+
// Remember the file so the next attempt can bypass Node's poisoned
|
|
95
|
+
// module cache if use-m hands us the same path again.
|
|
96
|
+
cleanedImportPath = /Failed to import module from '/.test(error?.message ?? '') ? corruptedPath : null;
|
|
59
97
|
} catch {
|
|
60
98
|
// Best-effort cleanup; fall through to retry regardless.
|
|
61
99
|
}
|
|
@@ -66,6 +104,20 @@ export const useWithRetry = async (use, specifier, options = {}) => {
|
|
|
66
104
|
throw lastError;
|
|
67
105
|
};
|
|
68
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Mode 4 (issue #2092): use-m's own `npm install -g <pkg>` step failed, so no
|
|
109
|
+
* package tree exists yet — `Failed to install command-stream@latest globally
|
|
110
|
+
* into '/home/box/.nvm/.../node_modules'.` This is transient in Docker-in-Docker
|
|
111
|
+
* runs where the registry (or DNS) is briefly unreachable, so retry with backoff.
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} error
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export const isTransientInstallError = error => {
|
|
117
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
118
|
+
return /^Failed to install .+ globally into /.test(message);
|
|
119
|
+
};
|
|
120
|
+
|
|
69
121
|
export const isCorruptInstallError = error => {
|
|
70
122
|
const cause = error?.cause;
|
|
71
123
|
if (cause instanceof SyntaxError) return true;
|
|
@@ -101,7 +153,72 @@ export const extractCorruptedFilePath = error => {
|
|
|
101
153
|
return invalidConfigMatch ? invalidConfigMatch[1] : null;
|
|
102
154
|
};
|
|
103
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Walk a corrupted path up to the use-m alias install directory.
|
|
158
|
+
*
|
|
159
|
+
* Issue #2092: the failing file can be nested several levels deep inside the
|
|
160
|
+
* package (`.../command-stream-v-latest/src/$.mjs`). Removing only its parent
|
|
161
|
+
* directory (`.../src`) leaves a half-package on disk whose package.json still
|
|
162
|
+
* resolves, so the retry re-imports the same broken tree. Walking up to the
|
|
163
|
+
* `<pkg>-v-<version>` alias segment removes the whole install instead.
|
|
164
|
+
*
|
|
165
|
+
* Falls back to the immediate parent directory when no alias segment is found.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} corruptedPath - file or directory path from the error message.
|
|
168
|
+
* @returns {string} directory to delete before retrying.
|
|
169
|
+
*/
|
|
170
|
+
export const resolveAliasDir = corruptedPath => {
|
|
171
|
+
const segments = corruptedPath.split('/');
|
|
172
|
+
const isAlias = segment => /-v-(latest|\d[^/]*)$/.test(segment);
|
|
173
|
+
for (let index = segments.length - 1; index >= 0; index--) {
|
|
174
|
+
if (isAlias(segments[index])) return segments.slice(0, index + 1).join('/');
|
|
175
|
+
}
|
|
176
|
+
return segments.slice(0, -1).join('/') || corruptedPath;
|
|
177
|
+
};
|
|
178
|
+
|
|
104
179
|
const defaultCleanup = async path => {
|
|
105
180
|
const { rm } = await import('node:fs/promises');
|
|
106
181
|
await rm(path, { recursive: true, force: true });
|
|
107
182
|
};
|
|
183
|
+
|
|
184
|
+
// Cache-busting import: a query string makes Node treat the URL as a distinct
|
|
185
|
+
// module, so the freshly reinstalled file is evaluated instead of the cached
|
|
186
|
+
// SyntaxError from the corrupt one.
|
|
187
|
+
const defaultImport = async (filePath, attempt) => {
|
|
188
|
+
const { pathToFileURL } = await import('node:url');
|
|
189
|
+
return import(`${pathToFileURL(filePath).href}?use-m-retry=${attempt}`);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
193
|
+
|
|
194
|
+
// Off by default so normal runs stay quiet; issue #2092 showed that when the
|
|
195
|
+
// loader dies there is no trace of which specifier or attempt failed.
|
|
196
|
+
const defaultLog = message => {
|
|
197
|
+
if (process.env.HIVE_MIND_USE_M_DEBUG) console.error(`[use-m] ${message}`);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const USE_RETRY_WRAPPED = Symbol.for('hive-mind.use-with-retry.wrapped');
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Wrap a raw use-m `use` function so that *every* call site inherits the
|
|
204
|
+
* corrupt-install recovery above (issue #2092).
|
|
205
|
+
*
|
|
206
|
+
* Before this, only the handful of call sites that explicitly imported
|
|
207
|
+
* `useWithRetry` (config/queue-config/lino) were protected, while ~40 other
|
|
208
|
+
* modules called `await use('command-stream')` directly and crashed with
|
|
209
|
+
* `Failed to import module from '.../command-stream-v-latest/src/$.mjs'.`
|
|
210
|
+
* whenever the global npm install was truncated.
|
|
211
|
+
*
|
|
212
|
+
* The wrapper is idempotent: wrapping an already-wrapped function returns it
|
|
213
|
+
* unchanged, so repeated `ensureUseM()` calls don't nest retries.
|
|
214
|
+
*
|
|
215
|
+
* @param {Function} use - raw use-m loader.
|
|
216
|
+
* @param {object} [options] - forwarded to useWithRetry (attempts, cleanup).
|
|
217
|
+
* @returns {Function} retry-wrapped loader.
|
|
218
|
+
*/
|
|
219
|
+
export const wrapUseWithRetry = (use, options = {}) => {
|
|
220
|
+
if (typeof use !== 'function' || use[USE_RETRY_WRAPPED]) return use;
|
|
221
|
+
const wrapped = (specifier, ...args) => useWithRetry(use, specifier, { ...options, args });
|
|
222
|
+
Object.defineProperty(wrapped, USE_RETRY_WRAPPED, { value: true });
|
|
223
|
+
return wrapped;
|
|
224
|
+
};
|