fix(memory): retry mcporter after Windows EINVAL spawn

This commit is contained in:
Vignesh Natarajan
2026-03-05 22:27:25 -08:00
parent 44ec3e4111
commit 30c0f7e89f
3 changed files with 144 additions and 59 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.
- Tools/web_search Brave language-code validation: align `search_lang` handling with Brave-supported codes (including `zh-hans`, `zh-hant`, `en-gb`, and `pt-br`), map common alias inputs (`zh`, `ja`) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.
- Models/openai-completions streaming compatibility: force `compat.supportsUsageInStreaming=false` for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering `choices[0]` parser crashes in provider streams. (#8714) Thanks @nonanon1.
- Tools/xAI native web-search collision guard: drop OpenClaw `web_search` from tool registration when routing to xAI/Grok model providers (including OpenRouter `x-ai/*`) to avoid duplicate tool-name request failures against provider-native `web_search`. (#14749) Thanks @realsamrat.

View File

@@ -1624,6 +1624,57 @@ describe("QmdMemoryManager", () => {
}
});
it("retries mcporter search with bare command on Windows EINVAL cmd-shim failures", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
try {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
let sawRetry = false;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (args[0] === "call" && typeof cmd === "string" && cmd.toLowerCase().endsWith(".cmd")) {
const child = createMockChild({ autoClose: false });
queueMicrotask(() => {
const err = Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" });
child.emit("error", err);
});
return child;
}
if (args[0] === "call" && cmd === "mcporter") {
sawRetry = true;
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
await expect(
manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }),
).resolves.toEqual([]);
expect(sawRetry).toBe(true);
expect(logWarnMock).toHaveBeenCalledWith(
expect.stringContaining("retrying with bare mcporter"),
);
await manager.close();
} finally {
platformSpy.mockRestore();
}
});
it("passes manager-scoped XDG env to mcporter commands", async () => {
cfg = {
...cfg,

View File

@@ -87,6 +87,17 @@ function resolveSpawnInvocation(params: {
return materializeWindowsSpawnProgram(program, params.args);
}
function isWindowsCmdSpawnEinval(err: unknown, command: string): boolean {
if (process.platform !== "win32") {
return false;
}
const errno = err as NodeJS.ErrnoException | undefined;
if (errno?.code !== "EINVAL") {
return false;
}
return /(^|[\\/])mcporter\.cmd$/i.test(command);
}
function hasHanScript(value: string): boolean {
return HAN_SCRIPT_RE.test(value);
}
@@ -1330,67 +1341,89 @@ export class QmdMemoryManager implements MemorySearchManager {
args: string[],
opts?: { timeoutMs?: number },
): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const spawnInvocation = resolveSpawnInvocation({
command: "mcporter",
args,
env: this.env,
packageName: "mcporter",
});
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
// Keep mcporter and direct qmd commands on the same agent-scoped XDG state.
env: this.env,
cwd: this.workspaceDir,
shell: spawnInvocation.shell,
windowsHide: spawnInvocation.windowsHide,
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
const timer = opts?.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
}, opts.timeoutMs)
: null;
child.stdout.on("data", (data) => {
const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars);
stdout = next.text;
stdoutTruncated = stdoutTruncated || next.truncated;
});
child.stderr.on("data", (data) => {
const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars);
stderr = next.text;
stderrTruncated = stderrTruncated || next.truncated;
});
child.on("error", (err) => {
if (timer) {
clearTimeout(timer);
}
reject(err);
});
child.on("close", (code) => {
if (timer) {
clearTimeout(timer);
}
if (stdoutTruncated || stderrTruncated) {
reject(
new Error(
`mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`,
),
);
return;
}
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(
new Error(`mcporter ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`),
);
}
const runWithInvocation = async (spawnInvocation: {
command: string;
argv: string[];
shell?: boolean;
windowsHide?: boolean;
}): Promise<{ stdout: string; stderr: string }> =>
await new Promise((resolve, reject) => {
const commandSummary = `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`;
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
// Keep mcporter and direct qmd commands on the same agent-scoped XDG state.
env: this.env,
cwd: this.workspaceDir,
shell: spawnInvocation.shell,
windowsHide: spawnInvocation.windowsHide,
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
const timer = opts?.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
}, opts.timeoutMs)
: null;
child.stdout.on("data", (data) => {
const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars);
stdout = next.text;
stdoutTruncated = stdoutTruncated || next.truncated;
});
child.stderr.on("data", (data) => {
const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars);
stderr = next.text;
stderrTruncated = stderrTruncated || next.truncated;
});
child.on("error", (err) => {
if (timer) {
clearTimeout(timer);
}
reject(err);
});
child.on("close", (code) => {
if (timer) {
clearTimeout(timer);
}
if (stdoutTruncated || stderrTruncated) {
reject(
new Error(
`mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`,
),
);
return;
}
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`${commandSummary} failed (code ${code}): ${stderr || stdout}`));
}
});
});
const primaryInvocation = resolveSpawnInvocation({
command: "mcporter",
args,
env: this.env,
packageName: "mcporter",
});
try {
return await runWithInvocation(primaryInvocation);
} catch (err) {
if (!isWindowsCmdSpawnEinval(err, primaryInvocation.command)) {
throw err;
}
// Some Windows npm cmd shims can still throw EINVAL on spawn; retry through
// shell command resolution so PATH/PATHEXT can select a runnable entrypoint.
log.warn("mcporter.cmd spawn returned EINVAL on Windows; retrying with bare mcporter");
return await runWithInvocation({
command: "mcporter",
argv: args,
shell: true,
windowsHide: true,
});
}
}
private async runQmdSearchViaMcporter(params: {