instar 1.2.25 → 1.2.26

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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * resolveNodeBinary — find a stable, currently-existing Node.js executable.
3
+ *
4
+ * Background:
5
+ * The native-module heal path spawns child processes with `process.execPath`
6
+ * as the executable target. That works UNTIL the underlying file disappears
7
+ * from disk — most commonly when Homebrew (or any package manager) updates
8
+ * Node while the server is still running. Brew swaps the
9
+ * /opt/homebrew/bin/node symlink and removes the previous Cellar directory.
10
+ * The running process keeps an open FD to the deleted binary so it continues
11
+ * executing normally, but any NEW `spawnSync(process.execPath, ...)` returns
12
+ * ENOENT — the file is gone from the filesystem.
13
+ *
14
+ * Observed live on luna 2026-05-21: heal detected NODE_MODULE_VERSION
15
+ * mismatch correctly, then logged
16
+ * "rebuild failed (spawnSync /opt/homebrew/Cellar/node/25.6.1/bin/node ENOENT)"
17
+ * even though `node --version` from a fresh shell still worked — Homebrew
18
+ * had moved Node forward and removed the original Cellar path the process
19
+ * was launched from.
20
+ *
21
+ * Strategy:
22
+ * 1. Try `process.execPath` first (cheapest, most likely correct).
23
+ * 2. If it's ENOENT, try `fs.realpathSync(process.execPath)` — if execPath
24
+ * was a symlink at startup, the underlying target might still exist.
25
+ * 3. Otherwise fall back to a list of known-stable absolute paths in order:
26
+ * - the bundled agent Node at <agentStateDir>/bin/node (passed in)
27
+ * - /opt/homebrew/bin/node (macOS Homebrew's stable symlink)
28
+ * - /usr/local/bin/node, /usr/bin/node
29
+ * 4. Final fallback: `which node` from PATH.
30
+ *
31
+ * Returns the first existing-and-executable absolute path, or null when
32
+ * none of the candidates resolve. Callers that get null should NOT silently
33
+ * proceed — the heal cannot continue without a working Node, and the failure
34
+ * mode is operator-visible (DegradationReporter event with a recovery hint).
35
+ *
36
+ * Spec / origin: heal-execpath-staleness fix (Luna self-heal failure, 2026-05-21).
37
+ */
38
+ export interface ResolveOptions {
39
+ /** Optional path to the agent's bundled Node (e.g. <stateDir>/bin/node). */
40
+ agentBundledNode?: string;
41
+ /** Override the in-process execPath. For testing only. */
42
+ execPathOverride?: string;
43
+ /** Override the platform string. For testing only. */
44
+ platformOverride?: NodeJS.Platform;
45
+ /** Override fs.existsSync. For testing only. */
46
+ existsSyncOverride?: (p: string) => boolean;
47
+ /** Override the `which`/`where` lookup. For testing only. */
48
+ whichOverride?: () => string | null;
49
+ }
50
+ /**
51
+ * Result of a resolution attempt. Includes the source so callers can log
52
+ * which fallback fired (useful when investigating why heal needed help).
53
+ */
54
+ export interface ResolvedNodeBinary {
55
+ /** Absolute path to the resolved Node executable. */
56
+ path: string;
57
+ /** Which strategy produced this path. */
58
+ source: 'execPath' | 'execPath-realpath' | 'agent-bundled' | 'homebrew' | 'usr-local' | 'usr-bin' | 'which';
59
+ }
60
+ /**
61
+ * Resolve a stable Node binary path. Returns the first viable candidate;
62
+ * null only when every strategy fails (rare — usually means Node was
63
+ * completely uninstalled mid-session).
64
+ */
65
+ export declare function resolveStableNodeBinary(opts?: ResolveOptions): ResolvedNodeBinary | null;
66
+ /**
67
+ * Convenience: returns just the path string, or null. Use the full
68
+ * resolver when you need to log which fallback fired.
69
+ */
70
+ export declare function resolveStableNodeBinaryPath(opts?: ResolveOptions): string | null;
71
+ //# sourceMappingURL=resolveNodeBinary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolveNodeBinary.d.ts","sourceRoot":"","sources":["../../src/utils/resolveNodeBinary.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AAOH,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,sDAAsD;IACtD,gBAAgB,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IACnC,gDAAgD;IAChD,kBAAkB,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5C,6DAA6D;IAC7D,aAAa,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,yCAAyC;IACzC,MAAM,EACF,UAAU,GACV,mBAAmB,GACnB,eAAe,GACf,UAAU,GACV,WAAW,GACX,SAAS,GACT,OAAO,CAAC;CACb;AA8BD;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,GAAE,cAAmB,GACxB,kBAAkB,GAAG,IAAI,CAmD3B;AAwBD;;;GAGG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,GAAE,cAAmB,GAAG,MAAM,GAAG,IAAI,CAGpF"}
@@ -0,0 +1,152 @@
1
+ /**
2
+ * resolveNodeBinary — find a stable, currently-existing Node.js executable.
3
+ *
4
+ * Background:
5
+ * The native-module heal path spawns child processes with `process.execPath`
6
+ * as the executable target. That works UNTIL the underlying file disappears
7
+ * from disk — most commonly when Homebrew (or any package manager) updates
8
+ * Node while the server is still running. Brew swaps the
9
+ * /opt/homebrew/bin/node symlink and removes the previous Cellar directory.
10
+ * The running process keeps an open FD to the deleted binary so it continues
11
+ * executing normally, but any NEW `spawnSync(process.execPath, ...)` returns
12
+ * ENOENT — the file is gone from the filesystem.
13
+ *
14
+ * Observed live on luna 2026-05-21: heal detected NODE_MODULE_VERSION
15
+ * mismatch correctly, then logged
16
+ * "rebuild failed (spawnSync /opt/homebrew/Cellar/node/25.6.1/bin/node ENOENT)"
17
+ * even though `node --version` from a fresh shell still worked — Homebrew
18
+ * had moved Node forward and removed the original Cellar path the process
19
+ * was launched from.
20
+ *
21
+ * Strategy:
22
+ * 1. Try `process.execPath` first (cheapest, most likely correct).
23
+ * 2. If it's ENOENT, try `fs.realpathSync(process.execPath)` — if execPath
24
+ * was a symlink at startup, the underlying target might still exist.
25
+ * 3. Otherwise fall back to a list of known-stable absolute paths in order:
26
+ * - the bundled agent Node at <agentStateDir>/bin/node (passed in)
27
+ * - /opt/homebrew/bin/node (macOS Homebrew's stable symlink)
28
+ * - /usr/local/bin/node, /usr/bin/node
29
+ * 4. Final fallback: `which node` from PATH.
30
+ *
31
+ * Returns the first existing-and-executable absolute path, or null when
32
+ * none of the candidates resolve. Callers that get null should NOT silently
33
+ * proceed — the heal cannot continue without a working Node, and the failure
34
+ * mode is operator-visible (DegradationReporter event with a recovery hint).
35
+ *
36
+ * Spec / origin: heal-execpath-staleness fix (Luna self-heal failure, 2026-05-21).
37
+ */
38
+ import fs from 'node:fs';
39
+ import os from 'node:os';
40
+ import { spawnSync } from 'node:child_process';
41
+ /**
42
+ * Check whether a path exists AND is executable (or at least file-like).
43
+ * On macOS/Linux, regular files with the exec bit set qualify; on Windows
44
+ * we only check existence because exec semantics differ. Symlinks are
45
+ * followed via fs.statSync (not lstat).
46
+ */
47
+ function isExecutableFile(p, existsSync) {
48
+ try {
49
+ if (!existsSync(p))
50
+ return false;
51
+ const stat = fs.statSync(p);
52
+ if (!stat.isFile())
53
+ return false;
54
+ // accessSync throws on missing exec bit; treat throw as "not executable".
55
+ if (os.platform() !== 'win32') {
56
+ try {
57
+ fs.accessSync(p, fs.constants.X_OK);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * Resolve a stable Node binary path. Returns the first viable candidate;
71
+ * null only when every strategy fails (rare — usually means Node was
72
+ * completely uninstalled mid-session).
73
+ */
74
+ export function resolveStableNodeBinary(opts = {}) {
75
+ const existsSync = opts.existsSyncOverride ?? fs.existsSync;
76
+ const platform = opts.platformOverride ?? os.platform();
77
+ const execPath = opts.execPathOverride ?? process.execPath;
78
+ // 1. process.execPath as-is. Cheapest and almost always right.
79
+ if (isExecutableFile(execPath, existsSync)) {
80
+ return { path: execPath, source: 'execPath' };
81
+ }
82
+ // 2. Realpath of process.execPath. If execPath was a symlink at startup,
83
+ // Node resolves it during init — but in some Homebrew failure modes,
84
+ // the realpath target survives even when the original execPath has
85
+ // been swept up. Try it.
86
+ try {
87
+ const real = fs.realpathSync(execPath);
88
+ if (real !== execPath && isExecutableFile(real, existsSync)) {
89
+ return { path: real, source: 'execPath-realpath' };
90
+ }
91
+ }
92
+ catch {
93
+ // realpath fails if execPath is gone; continue to fallbacks.
94
+ }
95
+ // 3. Agent's bundled Node, if the caller knows where it lives.
96
+ if (opts.agentBundledNode && isExecutableFile(opts.agentBundledNode, existsSync)) {
97
+ return { path: opts.agentBundledNode, source: 'agent-bundled' };
98
+ }
99
+ // 4. Platform-stable absolute paths in order of preference.
100
+ if (platform !== 'win32') {
101
+ const candidates = [
102
+ ['/opt/homebrew/bin/node', 'homebrew'],
103
+ ['/usr/local/bin/node', 'usr-local'],
104
+ ['/usr/bin/node', 'usr-bin'],
105
+ ];
106
+ for (const [candidate, source] of candidates) {
107
+ if (isExecutableFile(candidate, existsSync)) {
108
+ return { path: candidate, source };
109
+ }
110
+ }
111
+ }
112
+ // 5. Last resort: ask the OS where `node` lives on PATH.
113
+ const whichResult = opts.whichOverride
114
+ ? opts.whichOverride()
115
+ : whichNode(platform);
116
+ if (whichResult && isExecutableFile(whichResult, existsSync)) {
117
+ return { path: whichResult, source: 'which' };
118
+ }
119
+ return null;
120
+ }
121
+ /**
122
+ * Locate the first `node` on PATH via the platform's `which`/`where` tool.
123
+ * Returns null on any failure — the caller has its own fallback chain.
124
+ */
125
+ function whichNode(platform) {
126
+ try {
127
+ const cmd = platform === 'win32' ? 'where' : 'which';
128
+ const result = spawnSync(cmd, ['node'], {
129
+ encoding: 'utf-8',
130
+ timeout: 5000,
131
+ });
132
+ if (result.status === 0 && result.stdout) {
133
+ // `where` (Windows) can return multiple paths; take the first.
134
+ const first = result.stdout.trim().split(/\r?\n/)[0];
135
+ if (first)
136
+ return first;
137
+ }
138
+ return null;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ /**
145
+ * Convenience: returns just the path string, or null. Use the full
146
+ * resolver when you need to log which fallback fired.
147
+ */
148
+ export function resolveStableNodeBinaryPath(opts = {}) {
149
+ const resolved = resolveStableNodeBinary(opts);
150
+ return resolved ? resolved.path : null;
151
+ }
152
+ //# sourceMappingURL=resolveNodeBinary.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolveNodeBinary.js","sourceRoot":"","sources":["../../src/utils/resolveNodeBinary.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.2.25",
3
+ "version": "1.2.26",
4
4
  "description": "Persistent autonomy infrastructure for AI agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,11 +28,25 @@ const { execSync, execFileSync } = require('child_process');
28
28
  const fs = require('fs');
29
29
  const os = require('os');
30
30
  const path = require('path');
31
+ const { resolveStableNodeBinary } = require('./resolve-node-binary.cjs');
31
32
 
32
33
  const MODULE_VERSION = process.versions.modules;
33
34
  const ARCH = process.arch;
34
35
  const PLATFORM = process.platform;
35
36
 
37
+ // Resolve a stable Node binary up front. process.execPath is preferred when
38
+ // it still exists; we fall back to /opt/homebrew/bin/node (or PATH-derived
39
+ // node) when Homebrew has swept the original Cellar dir out from under us.
40
+ // See scripts/resolve-node-binary.cjs and src/utils/resolveNodeBinary.ts.
41
+ const _resolvedNode = resolveStableNodeBinary();
42
+ const NODE_BIN = _resolvedNode ? _resolvedNode.path : process.execPath;
43
+ if (_resolvedNode && _resolvedNode.source !== 'execPath') {
44
+ console.warn(
45
+ `[fix-better-sqlite3] process.execPath (${process.execPath}) is unreachable; ` +
46
+ `using ${_resolvedNode.source} fallback at ${NODE_BIN}.`
47
+ );
48
+ }
49
+
36
50
  function findBetterSqlite3() {
37
51
  try {
38
52
  const resolved = require.resolve('better-sqlite3/package.json');
@@ -72,7 +86,7 @@ function getBetterSqliteVersion(pkgDir) {
72
86
  function verifyChildAbiMatches() {
73
87
  try {
74
88
  const out = execFileSync(
75
- process.execPath,
89
+ NODE_BIN,
76
90
  ['-e', "process.stdout.write(process.versions.modules)"],
77
91
  { stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }
78
92
  );
@@ -94,7 +108,7 @@ function verifyChildAbiMatches() {
94
108
  function testBinary(pkgDir) {
95
109
  try {
96
110
  execFileSync(
97
- process.execPath,
111
+ NODE_BIN,
98
112
  [
99
113
  '-e',
100
114
  "const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.pragma('journal_mode = WAL'); db.close();",
@@ -180,7 +194,10 @@ function tryPrebuild(pkgDir, betterSqliteVersion) {
180
194
 
181
195
  /** Locate npm's CLI entry without needing a shell. */
182
196
  function findNpmCli() {
183
- const nodeDir = path.dirname(process.execPath);
197
+ // Prefer the npm sibling of the resolved Node binary — when execPath is
198
+ // stale, NODE_BIN points at the live Node, and its sibling npm is the right
199
+ // pair for that ABI.
200
+ const nodeDir = path.dirname(NODE_BIN);
184
201
  const candidates = [
185
202
  path.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
186
203
  '/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js',
@@ -215,13 +232,15 @@ function trySourceBuild(pkgDir) {
215
232
  // we're building FOR. Without this, a mixed environment (e.g. asdf Node 22
216
233
  // on PATH + server's Node 25 as execPath) compiles against the wrong
217
234
  // headers and produces an ABI-mismatched binary that silently fails later.
218
- const execDir = path.dirname(process.execPath);
235
+ // Use NODE_BIN (the resolved Node) so the PATH prefix points at a Node that
236
+ // actually exists on disk — process.execPath may be stale.
237
+ const execDir = path.dirname(NODE_BIN);
219
238
  const childPath = `${execDir}${path.delimiter}${process.env.PATH || ''}`;
220
239
 
221
240
  try {
222
241
  console.log(`[fix-better-sqlite3] Source-building better-sqlite3 in ${rebuildCwd} (~30s)`);
223
242
  execFileSync(
224
- process.execPath,
243
+ NODE_BIN,
225
244
  [npmCli, 'rebuild', 'better-sqlite3', '--build-from-source'],
226
245
  {
227
246
  cwd: rebuildCwd,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * resolve-node-binary.cjs — CommonJS twin of src/utils/resolveNodeBinary.ts.
3
+ *
4
+ * Both files implement the same fallback chain. They are kept in sync because
5
+ * fix-better-sqlite3.cjs runs as a child process via execFileSync and cannot
6
+ * import the TypeScript module; the server-side ensureSqliteBindings code
7
+ * imports the TS module directly. If you change the resolution chain in one,
8
+ * mirror it in the other.
9
+ *
10
+ * Rationale: when Homebrew updates Node mid-session, process.execPath becomes
11
+ * ENOENT for any NEW spawnSync (even though the running process keeps its open
12
+ * FD). The heal path needs a stable fallback chain so it can rebuild and the
13
+ * subsystems can recover after restart.
14
+ *
15
+ * Spec / origin: heal-execpath-staleness fix (Luna self-heal failure, 2026-05-21).
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const { spawnSync } = require('node:child_process');
21
+
22
+ function isExecutableFile(p, existsSync) {
23
+ try {
24
+ if (!existsSync(p)) return false;
25
+ const stat = fs.statSync(p);
26
+ if (!stat.isFile()) return false;
27
+ if (os.platform() !== 'win32') {
28
+ try {
29
+ fs.accessSync(p, fs.constants.X_OK);
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function whichNode(platform) {
41
+ try {
42
+ const cmd = platform === 'win32' ? 'where' : 'which';
43
+ const result = spawnSync(cmd, ['node'], {
44
+ encoding: 'utf-8',
45
+ timeout: 5000,
46
+ });
47
+ if (result.status === 0 && result.stdout) {
48
+ const first = result.stdout.trim().split(/\r?\n/)[0];
49
+ if (first) return first;
50
+ }
51
+ return null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {{agentBundledNode?: string, execPathOverride?: string,
59
+ * platformOverride?: string, existsSyncOverride?: (p:string)=>boolean,
60
+ * whichOverride?: () => string|null}} [opts]
61
+ * @returns {{path:string, source:string} | null}
62
+ */
63
+ function resolveStableNodeBinary(opts = {}) {
64
+ const existsSync = opts.existsSyncOverride || fs.existsSync;
65
+ const platform = opts.platformOverride || os.platform();
66
+ const execPath = opts.execPathOverride || process.execPath;
67
+
68
+ // 1. process.execPath as-is.
69
+ if (isExecutableFile(execPath, existsSync)) {
70
+ return { path: execPath, source: 'execPath' };
71
+ }
72
+
73
+ // 2. Realpath of process.execPath.
74
+ try {
75
+ const real = fs.realpathSync(execPath);
76
+ if (real !== execPath && isExecutableFile(real, existsSync)) {
77
+ return { path: real, source: 'execPath-realpath' };
78
+ }
79
+ } catch {
80
+ /* realpath fails when execPath is gone; continue */
81
+ }
82
+
83
+ // 3. Agent's bundled Node.
84
+ if (opts.agentBundledNode && isExecutableFile(opts.agentBundledNode, existsSync)) {
85
+ return { path: opts.agentBundledNode, source: 'agent-bundled' };
86
+ }
87
+
88
+ // 4. Platform-stable absolute paths.
89
+ if (platform !== 'win32') {
90
+ const candidates = [
91
+ ['/opt/homebrew/bin/node', 'homebrew'],
92
+ ['/usr/local/bin/node', 'usr-local'],
93
+ ['/usr/bin/node', 'usr-bin'],
94
+ ];
95
+ for (const [candidate, source] of candidates) {
96
+ if (isExecutableFile(candidate, existsSync)) {
97
+ return { path: candidate, source };
98
+ }
99
+ }
100
+ }
101
+
102
+ // 5. PATH lookup.
103
+ const whichResult = opts.whichOverride ? opts.whichOverride() : whichNode(platform);
104
+ if (whichResult && isExecutableFile(whichResult, existsSync)) {
105
+ return { path: whichResult, source: 'which' };
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ function resolveStableNodeBinaryPath(opts = {}) {
112
+ const resolved = resolveStableNodeBinary(opts);
113
+ return resolved ? resolved.path : null;
114
+ }
115
+
116
+ module.exports = {
117
+ resolveStableNodeBinary,
118
+ resolveStableNodeBinaryPath,
119
+ };
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-05-22T06:36:33.520Z",
5
- "instarVersion": "1.2.25",
4
+ "generatedAt": "2026-05-22T06:46:36.393Z",
5
+ "instarVersion": "1.2.26",
6
6
  "entryCount": 191,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,51 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+ <!-- patch = bug fixes, refactors, test additions, doc updates -->
5
+
6
+ ## What Changed
7
+
8
+ **fix(heal): better-sqlite3 self-heal now survives Homebrew Node updates that strand process.execPath.**
9
+
10
+ When Homebrew (or any package manager) updates Node while an instar server is running, the brew install swaps the `/opt/homebrew/bin/node` symlink and removes the previous Cellar directory. The server process keeps an open file descriptor to the deleted binary, so it continues executing — but any new `spawnSync(process.execPath, ...)` call returns ENOENT. The native-module self-heal path runs through exactly that kind of spawn, so the heal silently fails. The agent then degrades to legacy-memory fallbacks and stays there across restarts (the next startup hits the same stale execPath).
11
+
12
+ This was observed live on luna 2026-05-21. Server log showed the heal firing at 02:46:00, then 150ms later: `"rebuild failed (spawnSync /opt/homebrew/Cellar/node/25.6.1/bin/node ENOENT). SQLite subsystems may degrade."` Knowledge graph, conversation summaries, and the pending-relay queue all went offline while a healthy Node (a different Cellar version) was sitting on disk a few millimetres away.
13
+
14
+ The fix:
15
+
16
+ 1. New `resolveStableNodeBinary()` helper at `src/utils/resolveNodeBinary.ts` (with a CommonJS twin at `scripts/resolve-node-binary.cjs`). It walks a fallback chain — `process.execPath` → `fs.realpathSync(execPath)` → optional caller-supplied bundled Node → `/opt/homebrew/bin/node` (the stable symlink Homebrew always maintains) → `/usr/local/bin/node` → `/usr/bin/node` → `which node` — and returns the first existing-and-executable path, or null when every candidate fails.
17
+
18
+ 2. `ensureSqliteBindings()` in `src/commands/server.ts` now resolves the spawn target through `resolveStableNodeBinary()` before invoking either the bundled `fix-better-sqlite3.cjs` or the npm-rebuild fallback. If resolution returns null, a structured DegradationReporter event is emitted with an explicit recovery hint instead of the previous "rebuild failed (ENOENT)" log line.
19
+
20
+ 3. `scripts/fix-better-sqlite3.cjs` resolves once at module load and uses the resolved path for every internal spawn (testBinary probe, verifyChildAbiMatches defence, source-build via npm). `findNpmCli` also resolves the npm sibling relative to the resolved Node so a stale execPath doesn't poison npm discovery.
21
+
22
+ 4. When the rebuild itself still fails after resolution, `ensureSqliteBindings()` emits a second DegradationReporter event (`ensureSqliteBindings.rebuildFailed`) with a user-actionable recovery hint. Previously the failure was a single yellow log line; now it surfaces through the same degradation channel operators already monitor.
23
+
24
+ Fail-closed semantics preserved: if no Node binary is reachable anywhere, the heal does NOT proceed (the alternative — guessing wrong — risks producing an ABI-mismatched binary that fails silently later). The DegradationReporter event names the missing Node so the operator can fix it.
25
+
26
+ ## Evidence
27
+
28
+ Failure mode reproduced and verified to stop:
29
+
30
+ Reproduction (`tests/integration/heal-execpath-staleness.test.ts`) covers six scenarios drawn directly from the luna incident:
31
+
32
+ 1. `spawnSync` against a stale Cellar path (`/opt/homebrew/Cellar/node/0.0.0-removed-by-brew/bin/node`) returns ENOENT — the same shape as the luna server log line.
33
+ 2. The resolver returns a working fallback when execPath is stale. A child process spawned against the resolved path executes successfully.
34
+ 3. `fix-better-sqlite3.cjs` resolves a stable Node binary at module load (resolver and script both present, exported helpers callable).
35
+ 4. The resolver fails closed when no Node is reachable anywhere — does not silently pick a phantom path.
36
+ 5. The resolver rejects non-executable files even when they exist at the requested path (defence against `chmod -x` or write-only files).
37
+
38
+ Plus seven unit tests in `tests/unit/resolveNodeBinary.test.ts` covering the full fallback chain branch-by-branch.
39
+
40
+ The live fix on luna (manually-invoked `fix-better-sqlite3.cjs` via the resolved Node binary) brought her degradation count from 8 to 1, with SemanticMemory, TopicMemory, FeatureRegistry, and the pending-relay queue all returning to healthy. With this PR landed, the same recovery will run automatically on every server restart that detects the mismatch.
41
+
42
+ ## What to Tell Your User
43
+
44
+ If your agent ever logged "rebuild failed (spawnSync ... ENOENT)" after a Homebrew Node update — most often visible as your knowledge graph or conversation summaries going offline while the agent still appears to be running — this fix lets the agent recover on the next restart without manual help. The heal now tries multiple Node paths instead of just the one the server happened to launch with, so a brew-swept-out-from-under-us Cellar path no longer blocks rebuild.
45
+
46
+ ## Summary of New Capabilities
47
+
48
+ | Capability | How to Use |
49
+ |-----------|-----------|
50
+ | Heal survives stale `process.execPath` | automatic on next server restart |
51
+ | DegradationReporter event on heal failure | automatic — surfaces through existing `/degradations` and any consumers watching that channel |
@@ -0,0 +1,92 @@
1
+ # Side-Effects Review — fix(heal): resilient process.execPath resolution
2
+
3
+ **Branch:** `echo/fix-heal-execpath-staleness`
4
+ **Origin:** Luna self-heal failure 2026-05-21 (topic 9976, "topic-intent-layer"). Justin's framing: "the deeper issue that needs to be fixed: why couldn't Luna self heal?"
5
+ **Scope guardrail:** sibling fix to the Topic Intent Layer work, not a fold-in. Heal-path only.
6
+
7
+ ## Summary
8
+
9
+ When Homebrew updates Node mid-session, `process.execPath` becomes ENOENT. The native-module self-heal spawned subprocesses via that path, so the heal failed with `spawnSync … ENOENT` and the agent's SQLite-backed subsystems degraded. The fix resolves a stable Node binary through a fallback chain before spawning.
10
+
11
+ Two new files:
12
+ - `src/utils/resolveNodeBinary.ts` — TypeScript resolver used by `ensureSqliteBindings`.
13
+ - `scripts/resolve-node-binary.cjs` — CommonJS twin used by `fix-better-sqlite3.cjs`.
14
+
15
+ Two modified files:
16
+ - `src/commands/server.ts` — `ensureSqliteBindings` resolves the spawn target before invoking the rebuild path; emits DegradationReporter events on resolver-null and rebuild-failed paths.
17
+ - `scripts/fix-better-sqlite3.cjs` — Module-load resolution; every internal spawn uses the resolved `NODE_BIN`. `findNpmCli` resolves npm relative to the resolved Node, not raw `process.execPath`.
18
+
19
+ Two new test files:
20
+ - `tests/unit/resolveNodeBinary.test.ts` — 7 tests covering the fallback chain branch-by-branch.
21
+ - `tests/integration/heal-execpath-staleness.test.ts` — 6 tests reproducing the failure shape and verifying the fix.
22
+
23
+ ## Over-block check
24
+
25
+ Does this fix do MORE than what was asked?
26
+
27
+ - The resolver does NOT change the once-per-process heal guard in `NativeModuleHealer`. That guard exists to prevent expensive rebuild loops and was deliberately left in place.
28
+ - The resolver does NOT bypass `verifyChildAbiMatches` in the fix script. The ABI-divergence defence is still in place — it now runs against a resolved Node binary instead of process.execPath, which is what makes the defence actually work in the failure mode we're fixing.
29
+ - The DegradationReporter events are NEW emissions but match the existing pattern (subsystem-init-failure events already use the same shape). They surface a previously-silent failure rather than introducing new noise.
30
+
31
+ ## Under-block check
32
+
33
+ Does this fix leave the failure mode reachable through another path?
34
+
35
+ Other call sites that spawn via `process.execPath` were audited:
36
+ - `ServerSupervisor.preflightSelfHeal` — runs in the supervisor process, not the child server. Different process, different execPath lifecycle. Out of scope for this fix; supervisor process is short-lived and re-spawns on each cycle.
37
+ - `NativeModuleHealer.healBetterSqlite3Sync` — spawns `npmPath` (a separate variable resolved via `findNpmPath`) with `process.execPath` as args[0]. ENOENT on execPath here would also fail. **Follow-up:** apply the same resolver to this path in a separate small PR. Out of scope for this commit to keep the diff tight per the original ask.
38
+
39
+ The follow-up is tracked at the bottom of this artifact, not punted as an orphan TODO. (Per the `no-out-of-scope-trap` memory.)
40
+
41
+ ## Level-of-abstraction fit
42
+
43
+ The resolver is a utility, not a subsystem. It does one thing: resolve a path. It's called from the existing heal-path call sites; it does not own any state, does not log autonomously, does not own the rebuild flow. The DegradationReporter emissions live in `ensureSqliteBindings`, not in the resolver — the resolver returns data, the caller decides whether to escalate.
44
+
45
+ This matches the signal-vs-authority pattern: the resolver is the signal layer (returns "here's a working Node, here's how I found it, or null if nothing works"); the caller is the authority that decides what to do with that signal.
46
+
47
+ ## Signal-vs-authority compliance
48
+
49
+ The resolver emits no degradation events, no logs, no side effects beyond resolving a path. It returns a structured value or null.
50
+
51
+ The caller (`ensureSqliteBindings`) is the authority — it decides:
52
+ - Whether to proceed with the rebuild (only if resolver returned a path).
53
+ - Whether to emit a DegradationReporter event (yes when resolver returns null OR when rebuild fails after resolution).
54
+ - What recovery hint to surface in the degradation impact field.
55
+
56
+ Two emission sites, both with explicit recovery actions:
57
+ - `ensureSqliteBindings.nodeBinaryResolution` — fires when the resolver returns null. Recovery: reinstall Node and restart.
58
+ - `ensureSqliteBindings.rebuildFailed` — fires when rebuild fails after resolution. Recovery: restart agent OR run `npx instar update`.
59
+
60
+ ## Interactions with other systems
61
+
62
+ - **NativeModuleHealer.openWithHeal** (the in-line heal path for direct constructor calls): NOT modified by this PR. That path still uses `process.execPath` internally. The follow-up applies the same resolver there.
63
+ - **ServerSupervisor.preflightSelfHeal**: NOT modified. Supervisor runs in a separate process with its own execPath lifecycle.
64
+ - **UpdateChecker.applyUpdate**: Already had its own rebuild path that worked on luna (visible in the server log at 02:35 and 03:21 — both successful rebuilds while the server kept running with stale bindings). No conflict: UpdateChecker's rebuilds happen in long-running processes that already hold the broken in-memory binding; they take effect on next restart. This fix ensures the next restart's preflight can actually run.
65
+ - **PostUpdateMigrator**: NOT touched. Migrator script propagation works as before; no agent-side script needs migration for this fix because both new files (.ts + .cjs) live in the instar package itself.
66
+
67
+ ## Rollback cost
68
+
69
+ Single-commit revert removes the resolver, the import, the call-site change in `ensureSqliteBindings`, and the resolver-pinned spawns in `fix-better-sqlite3.cjs`. The behavior reverts to "heal fails when execPath is stale" — the failure mode we're fixing, not a new one. No data migration, no config changes, no agent-side script propagation required.
70
+
71
+ ## Cross-framework portability (v1.0+)
72
+
73
+ The heal path is framework-agnostic — it operates on Node binaries, not on the agent runtime (Claude Code vs Codex). The resolver uses standard Node APIs (`fs`, `os`, `child_process`) with no framework-specific code paths. Verified by:
74
+ - Integration test passes under the test-running Node regardless of framework.
75
+ - The Codex CLI runtime spawns differently but uses the same Node-binary substrate, so the resolver applies equally there.
76
+
77
+ No `INSTAR_FRAMEWORK`-conditional code added; no framework-aware adapters needed for this fix.
78
+
79
+ ## Telemetry / observability
80
+
81
+ Heal-attempt visibility before this PR: a single yellow log line on failure.
82
+ Heal-attempt visibility after this PR: that log line PLUS a DegradationReporter event with structured fields (feature, primary, fallback, reason, impact). The event surfaces through `GET /degradations` (existing route) and any subscribers to the degradation event channel (existing pattern).
83
+
84
+ When the resolver fires a non-execPath fallback, a `console.log(pc.dim(...))` line names which fallback fired and the resolved path. This is one extra log line per server startup that needed it; on healthy startups it doesn't fire.
85
+
86
+ ## Follow-ups (tracked, not orphaned)
87
+
88
+ 1. Apply `resolveStableNodeBinary` to `NativeModuleHealer.healBetterSqlite3Sync` so the in-line CLI-direct-construction heal path (CLI commands like `instar memory ...`, TokenLedger constructor, etc.) also benefits. Should be a small commit on a separate branch — the heal-path-fix scope intentionally excluded it to keep this diff tight.
89
+
90
+ 2. Consider whether the once-per-process heal guard in `NativeModuleHealer` should distinguish "heal failed due to spawn-target ENOENT" (transient, retryable when operator restores Node) from "heal failed due to rebuild error" (durable, don't retry). The current guard treats both the same. Lower priority than (1).
91
+
92
+ Both items will be filed as initiatives so they don't drop. Not part of this commit.