fix(memory-host-sdk): resolve stable execPath for worker fork to survive Homebrew Node upgrades (#99318)

* fix(memory): survive Homebrew Node upgrades

Co-authored-by: 袁龙辉0668001277 <yuan.longhui@xydigit.com>

* test(whatsapp): isolate last-route coverage

* fix(deepinfra): preserve offline model compatibility

* test(memory): isolate migration cleanup lifecycle

* test: stabilize aggregate extension gates

* ci: retrigger pull request workflow

* test: preserve inherited Node options

* test(cli): tolerate cold hosted startup

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
loong
2026-07-29 18:49:02 +08:00
committed by GitHub
parent f27899181e
commit 7bdd1f646b
17 changed files with 300 additions and 64 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ describe("hasDeepInfraApiKey", () => {
describe("discoverDeepInfraModels (chat-only shim)", () => {
it("returns static catalog in test environment", async () => {
const models = await discoverDeepInfraModels();
const models = await discoverDeepInfraModels({ env: { VITEST: "true" } });
const modelIds = models.map((m) => m.id);
const streamingUsageIncompatibleModelIds = models
.filter((m) => !m.compat?.supportsUsageInStreaming)
+7 -2
View File
@@ -411,11 +411,11 @@ export async function discoverDeepInfraSurfaces(options?: {
env?: NodeJS.ProcessEnv;
agentDir?: string;
}): Promise<DeepInfraDiscoveredCatalog> {
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
const env = options?.env ?? process.env;
if (env.NODE_ENV === "test" || env.VITEST) {
return manifestFallbackCatalog();
}
const env = options?.env ?? process.env;
const hasKey = options?.hasApiKey ?? hasDeepInfraApiKey({ env, agentDir: options?.agentDir });
if (!hasKey) {
return manifestFallbackCatalog();
@@ -468,6 +468,11 @@ export async function discoverDeepInfraModels(options?: {
agentDir?: string;
}): Promise<ModelDefinitionConfig[]> {
const catalog = await discoverDeepInfraSurfaces(options);
if (!catalog.live) {
// Keep manifest-owned chat compatibility metadata intact. The generic
// surface projection intentionally carries only cross-surface fields.
return DEEPINFRA_MODEL_CATALOG.map(buildDeepInfraModelDefinition);
}
const chatModels = catalog.chat.length > 0 ? catalog.chat : [...catalog.chat, ...catalog.vlm];
if (chatModels.length === 0) {
// True empty (no manifest entries either) — keep behavior stable.
@@ -54,7 +54,8 @@ describe("Kimi implicit provider (#22409)", () => {
headers: {
"User-Agent": "claude-code/0.1.0",
},
models: [
// Credential-aware catalog assembly may prioritize the configured default.
models: expect.arrayContaining([
{
id: "kimi-for-coding",
name: "Kimi Code",
@@ -111,9 +112,10 @@ describe("Kimi implicit provider (#22409)", () => {
contextWindow: 262144,
maxTokens: 131072,
},
],
]),
apiKey: "test-key",
});
expect(provider.models).toHaveLength(4);
});
it("ignores retired kimi-coding provider overrides", async () => {
@@ -15,8 +15,7 @@ import {
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import "./test-runtime-mocks.js";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import { closeAllMemoryIndexManagers, MemoryIndexManager } from "./manager.js";
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
@@ -35,7 +34,7 @@ describe("memory legacy migration cleanup", () => {
afterEach(async () => {
await manager?.close();
manager = undefined;
await closeAllMemorySearchManagers();
await closeAllMemoryIndexManagers();
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
if (originalStateDir === undefined) {
@@ -160,11 +159,11 @@ describe("memory legacy migration cleanup", () => {
},
}) as OpenClawConfig;
const cfg = createConfig({ provider: "none", vectorEnabled: false });
const result = await getMemorySearchManager({ cfg, agentId: "main" });
if (!result.manager) {
throw new Error(result.error ?? "memory manager missing");
const result = await MemoryIndexManager.get({ cfg, agentId: "main" });
if (!result) {
throw new Error("memory manager missing");
}
manager = result.manager as unknown as MemoryIndexManager;
manager = result;
expect(manager.status().fts?.available).toBe(true);
expect(Reflect.get(manager, "sessionsFullRetryDirty")).toBe(false);
@@ -248,22 +247,25 @@ describe("memory legacy migration cleanup", () => {
} finally {
observerDb.close();
}
await closeAllMemorySearchManagers();
manager = undefined;
const reloadResult = await getMemorySearchManager({
cfg: createConfig({
extensionPath: vectorExtensionPath,
provider: "openai",
vectorEnabled: true,
}),
agentId: "main",
// Exercise the later vector-enabled load directly. Recreating the public
// manager here also tests unrelated provider/cache retirement lifecycles.
const vectorState = Reflect.get(manager, "vector") as {
available: boolean | null;
enabled: boolean;
extensionPath?: string;
};
const vectorDb = new DatabaseSync(dbPath, { allowExtension: true });
const managerLoaded = await loadSqliteVecExtension({
db: vectorDb,
extensionPath: vectorExtensionPath,
});
if (!reloadResult.manager) {
throw new Error(reloadResult.error ?? "reloaded memory manager missing");
}
manager = reloadResult.manager as unknown as MemoryIndexManager;
const reloadedDb = Reflect.get(manager, "db") as DatabaseSync;
expect(managerLoaded.ok, managerLoaded.error).toBe(true);
db.close();
Reflect.set(manager, "db", vectorDb);
vectorState.enabled = true;
vectorState.available = true;
vectorState.extensionPath = vectorExtensionPath;
Reflect.set(manager, "vectorReady", null);
await expect(
(
manager as unknown as {
@@ -271,12 +273,14 @@ describe("memory legacy migration cleanup", () => {
}
).loadVectorExtension(),
).resolves.toBe(false);
expect(reloadedDb.prepare("SELECT vec_version() AS version").get()).toEqual({
expect(vectorDb.prepare("SELECT vec_version() AS version").get()).toEqual({
version: expect.any(String),
});
expect(
reloadedDb.prepare("SELECT COUNT(*) AS count FROM memory_index_chunks_vec").get(),
).toEqual({ count: 2 });
expect(vectorDb.prepare("SELECT COUNT(*) AS count FROM memory_index_chunks_vec").get()).toEqual(
{
count: 2,
},
);
expect(Reflect.get(manager, "memoryFullRetryDirty")).toBe(true);
});
});
@@ -9,6 +9,19 @@ import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
const runChannelInboundEventMock = vi.hoisted(() =>
vi.fn(async () => ({ dispatched: false }) as never),
);
vi.mock("openclaw/plugin-sdk/channel-inbound", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/channel-inbound")>(
"openclaw/plugin-sdk/channel-inbound",
);
return {
...actual,
runChannelInboundEvent: runChannelInboundEventMock,
};
});
vi.mock("./auto-reply/monitor/last-route.js", async () => {
const actual = await vi.importActual<typeof import("./auto-reply/monitor/last-route.js")>(
@@ -107,6 +120,7 @@ describe("web auto-reply last-route", () => {
beforeEach(() => {
updateLastRouteInBackgroundMock.mockClear();
runChannelInboundEventMock.mockClear();
});
it("updates last-route for direct chats without senderE164", async () => {
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
import { beforeEach, expect, it, vi } from "vitest";
const forkMock = vi.hoisted(() => vi.fn());
const accessMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
@@ -12,10 +13,75 @@ vi.mock("node:child_process", async () => {
};
});
vi.mock("node:fs/promises", async () => {
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
return {
...actual,
default: { ...actual, access: accessMock },
access: accessMock,
};
});
import { createLocalEmbeddingWorkerProvider } from "./embeddings-worker.js";
beforeEach(() => {
forkMock.mockReset();
accessMock.mockReset().mockRejectedValue(new Error("missing"));
});
it("forks workers through a stable Homebrew Node path", async () => {
const originalExecPath = process.execPath;
Object.defineProperty(process, "execPath", {
configurable: true,
value: "/opt/homebrew/Cellar/node/26.5.0/bin/node",
});
accessMock.mockImplementation(async (candidate: string) => {
if (candidate === "/opt/homebrew/opt/node/bin/node") {
return;
}
throw new Error("missing");
});
const child = Object.assign(new EventEmitter(), {
connected: true,
exitCode: null as number | null,
signalCode: null as NodeJS.Signals | null,
disconnect: vi.fn(function (this: { connected: boolean }) {
this.connected = false;
}),
kill: vi.fn(function (this: EventEmitter, signal: NodeJS.Signals) {
queueMicrotask(() => this.emit("close", null, signal));
return true;
}),
send: vi.fn(function (
this: EventEmitter,
message: { id: number },
callback: (err?: Error | null) => void,
) {
callback();
queueMicrotask(() => this.emit("message", { id: message.id, ok: true }));
return true;
}),
});
forkMock.mockReturnValue(child);
try {
const provider = await createLocalEmbeddingWorkerProvider(
{ config: {} as never, provider: "local", model: "", fallback: "none" },
{ workerScriptPath: "/mock/worker.cjs" },
);
expect(forkMock).toHaveBeenCalledWith(
"/mock/worker.cjs",
[],
expect.objectContaining({ execPath: "/opt/homebrew/opt/node/bin/node" }),
);
await expect(provider.close?.()).resolves.toBeUndefined();
} finally {
Object.defineProperty(process, "execPath", {
configurable: true,
value: originalExecPath,
});
}
});
it("keeps an active worker alive when a queued embedding request is aborted", async () => {
@@ -1,8 +1,10 @@
// Memory Host SDK module implements embeddings worker behavior.
import { fork, type ChildProcess } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import { stableHomebrewNodePathCandidates } from "@openclaw/normalization-core/stable-node-path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { DEFAULT_LOCAL_MODEL } from "./embedding-defaults.js";
import {
@@ -233,6 +235,18 @@ function resolveWorkerExecArgv(): string[] {
return args;
}
async function resolveWorkerExecPath(nodePath: string): Promise<string> {
for (const candidate of stableHomebrewNodePathCandidates(nodePath)) {
try {
await fs.access(candidate);
return candidate;
} catch {
// Try the next Homebrew-managed stable path.
}
}
return nodePath;
}
/** IPC client that serializes local embedding calls through one child process. */
class LocalEmbeddingWorkerClient {
private child: ChildProcess | null = null;
@@ -243,7 +257,10 @@ class LocalEmbeddingWorkerClient {
private pending = new Map<number, PendingRequest>();
private lastRuntimeFacts: LocalEmbeddingRuntimeFacts | undefined;
constructor(private readonly scriptPath: string) {}
constructor(
private readonly scriptPath: string,
private readonly execPath: string,
) {}
/** Start or reuse the child worker and initialize its provider. */
async initialize(options: EmbeddingProviderOptions): Promise<void> {
@@ -340,6 +357,7 @@ class LocalEmbeddingWorkerClient {
}
const child = fork(this.scriptPath, [], {
execPath: this.execPath,
execArgv: resolveWorkerExecArgv(),
serialization: "json",
stdio: ["ignore", "ignore", "ignore", "ipc"],
@@ -653,8 +671,12 @@ async function createLocalEmbeddingWorkerProviderOnce(
): Promise<EmbeddingProvider> {
const modelPath = normalizeOptionalString(options.local?.modelPath) || DEFAULT_LOCAL_MODEL;
const workerOptions = serializeLocalEmbeddingOptions(options, runtimeOptions);
// Resolve before constructing the client so worker restarts stay synchronous.
// The stable Homebrew symlink can retarget without changing this stored path.
const workerExecPath = await resolveWorkerExecPath(process.execPath);
const client = new LocalEmbeddingWorkerClient(
runtimeOptions?.workerScriptPath ?? resolveDefaultWorkerScriptPath(),
workerExecPath,
);
try {
await client.initialize(workerOptions);
+6 -1
View File
@@ -69,6 +69,11 @@
"import": "./dist/string-normalization.mjs",
"default": "./dist/string-normalization.mjs"
},
"./stable-node-path": {
"types": "./dist/stable-node-path.d.mts",
"import": "./dist/stable-node-path.mjs",
"default": "./dist/stable-node-path.mjs"
},
"./utf16-slice": {
"types": "./dist/utf16-slice.d.mts",
"import": "./dist/utf16-slice.mjs",
@@ -76,7 +81,7 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"libphonenumber-js": "1.13.9"
@@ -0,0 +1,25 @@
import path from "node:path";
import { expectDefined } from "./expect.js";
/**
* Returns stable Homebrew paths for a versioned Cellar Node executable.
* Availability remains caller-owned so packages can reuse the path contract
* without importing another package's filesystem/runtime layer.
*/
export function stableHomebrewNodePathCandidates(nodePath: string): string[] {
const cellarMatch = nodePath.match(
/^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/,
);
if (!cellarMatch) {
return [];
}
const prefix = expectDefined(cellarMatch[1], "cellar match capture group 1");
const formula = expectDefined(cellarMatch[2], "cellar match capture group 2");
const pathModule = nodePath.includes("\\") ? path.win32 : path.posix;
const candidates = [pathModule.join(prefix, "opt", formula, "bin", "node")];
if (formula === "node") {
candidates.push(pathModule.join(prefix, "bin", "node"));
}
return candidates;
}
+7 -1
View File
@@ -24,6 +24,7 @@ import {
import {
applyDefaultMultiSpecVitestCachePaths,
applyDefaultVitestNoOutputTimeout,
applyFullExtensionsHeapBudget,
applyParallelVitestCachePaths,
buildFullSuiteVitestRunPlans,
createVitestPreflightPnpmArgs,
@@ -299,7 +300,12 @@ async function main() {
cwd: process.cwd(),
});
const runSpecs = applyDefaultMultiSpecVitestCachePaths(
applyDefaultVitestNoOutputTimeout(rawRunSpecs, { env: baseEnv }),
applyDefaultVitestNoOutputTimeout(
applyFullExtensionsHeapBudget(rawRunSpecs, { env: baseEnv }),
{
env: baseEnv,
},
),
{ cwd: process.cwd(), env: baseEnv },
);
+5
View File
@@ -76,6 +76,11 @@ export function resolveParallelFullSuiteConcurrency(
hostInfo?: VitestHostInfo,
): number;
export function applyFullExtensionsHeapBudget<T extends { config: string; env: NodeJS.ProcessEnv }>(
specs: T[],
params?: { env?: Record<string, string | undefined> },
): Array<Omit<T, "env"> & { env: NodeJS.ProcessEnv }>;
export function resolveChangedTargetArgs(
args: string[],
cwd?: string,
+39
View File
@@ -4850,6 +4850,45 @@ function hasConservativeVitestWorkerBudget(env) {
return workerBudget !== null && workerBudget <= 1;
}
const FULL_EXTENSIONS_CONFIG = "test/vitest/vitest.full-extensions.config.ts";
const FULL_EXTENSIONS_MIN_HEAP_MB = 8192;
function ensureMaxOldSpaceSize(nodeOptions, minimumMb) {
const normalized = nodeOptions?.trim() ?? "";
const matches = Array.from(
normalized.matchAll(/(^|\s)--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)(?=\s|$)/gu),
);
const match = matches.at(-1);
if (!match) {
return [normalized, `--max-old-space-size=${minimumMb}`].filter(Boolean).join(" ");
}
const currentMb = Number(match[2]);
if (Number.isSafeInteger(currentMb) && currentMb >= minimumMb) {
return normalized;
}
const start = match.index;
const replacement = match[0].replace(/\d+$/u, String(minimumMb));
return `${normalized.slice(0, start)}${replacement}${normalized.slice(start + match[0].length)}`;
}
export function applyFullExtensionsHeapBudget(specs, params = {}) {
const baseEnv = params.env ?? {};
return specs.map((spec) =>
spec.config === FULL_EXTENSIONS_CONFIG
? {
...spec,
env: {
...spec.env,
NODE_OPTIONS: ensureMaxOldSpaceSize(
spec.env?.NODE_OPTIONS ?? baseEnv.NODE_OPTIONS,
FULL_EXTENSIONS_MIN_HEAP_MB,
),
},
}
: spec,
);
}
export function resolveParallelFullSuiteConcurrency(specCount, envInput, hostInfo) {
let env = envInput;
env ??= process.env;
+3 -2
View File
@@ -13,8 +13,9 @@ import { registerSubCliByName } from "./program/register.subclis.js";
const execFileAsync = promisify(execFile);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
// Fork CI uses shared hosted runners where cold TSX startup can exceed 45 seconds.
const CHILD_PROCESS_TIMEOUT_MS = 75_000;
// This is a deadlock guard, not a startup SLO. Fork CI can take over a minute
// to cold-load the CLI graph on shared hosted runners, while still exiting correctly.
const CHILD_PROCESS_TIMEOUT_MS = 120_000;
const LAZY_GROUP_HELP_CASES = [
{ group: "backup", usageCommand: "backup", registry: "core" },
{ group: "capability", usageCommand: "infer|capability", registry: "subcli" },
+5 -28
View File
@@ -1,7 +1,6 @@
// Resolves Homebrew Node binary paths to stable symlink targets.
import fs from "node:fs/promises";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { stableHomebrewNodePathCandidates } from "@openclaw/normalization-core/stable-node-path";
/**
* Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node)
@@ -11,35 +10,13 @@ import { expectDefined } from "@openclaw/normalization-core";
* - Versioned formula "node@22": <prefix>/opt/node@22/bin/node (keg-only)
*/
export async function resolveStableNodePath(nodePath: string): Promise<string> {
const cellarMatch = nodePath.match(
/^(.+?)[\\/]Cellar[\\/]([^\\/]+)[\\/][^\\/]+[\\/]bin[\\/]node$/,
);
if (!cellarMatch) {
return nodePath;
}
const prefix = expectDefined(cellarMatch[1], "cellar match capture group 1"); // e.g. /opt/homebrew
const formula = expectDefined(cellarMatch[2], "cellar match capture group 2"); // e.g. "node" or "node@22"
const pathModule = nodePath.includes("\\") ? path.win32 : path.posix;
// Try the Homebrew opt symlink first — works for both default and versioned formulas.
const optPath = pathModule.join(prefix, "opt", formula, "bin", "node");
try {
await fs.access(optPath);
return optPath;
} catch {
// fall through
}
// For the default "node" formula, also try the direct bin symlink.
if (formula === "node") {
const binPath = pathModule.join(prefix, "bin", "node");
for (const candidate of stableHomebrewNodePathCandidates(nodePath)) {
try {
await fs.access(binPath);
return binPath;
await fs.access(candidate);
return candidate;
} catch {
// fall through
// Try the next Homebrew-managed stable path.
}
}
return nodePath;
}
+52
View File
@@ -12,6 +12,7 @@ import {
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS,
applyDefaultMultiSpecVitestCachePaths,
applyDefaultVitestNoOutputTimeout,
applyFullExtensionsHeapBudget,
applyParallelVitestCachePaths,
buildFullSuiteVitestRunPlans,
buildVitestArgs,
@@ -4640,6 +4641,57 @@ describe("scripts/test-projects full-suite sharding", () => {
}
});
it("gives only the aggregate extension shard an 8 GiB heap floor", () => {
const specs = applyFullExtensionsHeapBudget([
{
config: "test/vitest/vitest.full-extensions.config.ts",
env: { NODE_OPTIONS: "--trace-warnings --max-old-space-size=4096" },
},
{
config: "test/vitest/vitest.full-core-runtime.config.ts",
env: { NODE_OPTIONS: "--max-old-space-size=4096" },
},
]);
expect(specs[0]?.env.NODE_OPTIONS).toBe("--trace-warnings --max-old-space-size=8192");
expect(specs[1]?.env.NODE_OPTIONS).toBe("--max-old-space-size=4096");
});
it("preserves a larger aggregate extension heap override", () => {
const specs = applyFullExtensionsHeapBudget([
{
config: "test/vitest/vitest.full-extensions.config.ts",
env: { NODE_OPTIONS: "--max_old_space_size 12288 --trace-warnings" },
},
]);
expect(specs[0]?.env.NODE_OPTIONS).toBe("--max_old_space_size 12288 --trace-warnings");
});
it("preserves inherited Node options when the spec has no override", () => {
const specs = applyFullExtensionsHeapBudget(
[{ config: "test/vitest/vitest.full-extensions.config.ts", env: {} }],
{
env: {
NODE_OPTIONS: "--require ./test-hook.cjs --max-old-space-size=12288",
},
},
);
expect(specs[0]?.env.NODE_OPTIONS).toBe("--require ./test-hook.cjs --max-old-space-size=12288");
});
it("raises the effective last aggregate extension heap override", () => {
const specs = applyFullExtensionsHeapBudget([
{
config: "test/vitest/vitest.full-extensions.config.ts",
env: { NODE_OPTIONS: "--max-old-space-size=12288 --max_old_space_size=4096" },
},
]);
expect(specs[0]?.env.NODE_OPTIONS).toBe("--max-old-space-size=12288 --max_old_space_size=8192");
});
it("keeps explicit parallel overrides ahead of the host-aware profile", () => {
expect(
resolveParallelFullSuiteConcurrency(
+10
View File
@@ -482,6 +482,16 @@ export const sharedVitestConfig = {
find: "@openclaw/normalization-core/result",
replacement: path.join(repoRoot, "packages", "normalization-core", "src", "result.ts"),
},
{
find: "@openclaw/normalization-core/stable-node-path",
replacement: path.join(
repoRoot,
"packages",
"normalization-core",
"src",
"stable-node-path.ts",
),
},
{
find: "@openclaw/normalization-core/string-coerce",
replacement: path.join(
+3
View File
@@ -164,6 +164,9 @@
"@openclaw/normalization-core/result": [
"./packages/normalization-core/src/result.ts"
],
"@openclaw/normalization-core/stable-node-path": [
"./packages/normalization-core/src/stable-node-path.ts"
],
"@openclaw/normalization-core/string-coerce": [
"./packages/normalization-core/src/string-coerce.ts"
],