@link-assistant/hive-mind 2.8.9 → 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 CHANGED
@@ -1,5 +1,27 @@
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
+
3
25
  ## 2.8.9
4
26
 
5
27
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.8.9",
3
+ "version": "2.8.10",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/cleanup.mjs CHANGED
@@ -438,6 +438,8 @@ async function main() {
438
438
  }
439
439
 
440
440
  main().catch(async error => {
441
- await log(`❌ Error: ${error.message}`, { level: 'error' });
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
  });
@@ -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/fix.mjs CHANGED
@@ -236,7 +236,10 @@ async function main() {
236
236
  });
237
237
  }
238
238
 
239
- main().catch(error => {
240
- console.error(`❌ ${error.message}`);
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
  });
@@ -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
- globalThis.use = (await eval(await fetchUseMCode())).use;
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
- globalThis.use = (await eval(await fallbackFetchUseMCode())).use;
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
- if (attempt === attempts || !isCorruptInstallError(error)) {
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
- const { dirname } = await import('node:path');
57
- const target = corruptedPath.endsWith('-v-latest') || /-v-\d/.test(corruptedPath) ? corruptedPath : dirname(corruptedPath);
58
- await cleanup(target);
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
+ };