fix(memory): retry mcporter after Windows EINVAL spawn
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user