refactor: extract qmd process runner

This commit is contained in:
Peter Steinberger
2026-03-08 17:21:28 +00:00
parent 68775745d2
commit c5095153b0
2 changed files with 175 additions and 179 deletions

View File

@@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -8,11 +7,12 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { writeFileWithinRoot } from "../infra/fs-safe.js"; import { writeFileWithinRoot } from "../infra/fs-safe.js";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
import { isFileMissingError, statRegularFile } from "./fs-utils.js"; import { isFileMissingError, statRegularFile } from "./fs-utils.js";
import {
isWindowsCommandShimEinval,
resolveCliSpawnInvocation,
runCliCommand,
} from "./qmd-process.js";
import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js";
import { import {
listSessionFilesForAgent, listSessionFilesForAgent,
@@ -51,53 +51,6 @@ const QMD_BM25_HAN_KEYWORD_LIMIT = 12;
let qmdEmbedQueueTail: Promise<void> = Promise.resolve(); let qmdEmbedQueueTail: Promise<void> = Promise.resolve();
function resolveWindowsCommandShim(command: string): string {
if (process.platform !== "win32") {
return command;
}
const trimmed = command.trim();
if (!trimmed) {
return command;
}
const ext = path.extname(trimmed).toLowerCase();
if (ext === ".cmd" || ext === ".exe" || ext === ".bat") {
return command;
}
const base = path.basename(trimmed).toLowerCase();
if (base === "qmd" || base === "mcporter") {
return `${trimmed}.cmd`;
}
return command;
}
function resolveSpawnInvocation(params: {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
packageName: string;
}) {
const program = resolveWindowsSpawnProgram({
command: resolveWindowsCommandShim(params.command),
platform: process.platform,
env: params.env,
execPath: process.execPath,
packageName: params.packageName,
allowShellFallback: true,
});
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 { function hasHanScript(value: string): boolean {
return HAN_SCRIPT_RE.test(value); return HAN_SCRIPT_RE.test(value);
} }
@@ -1235,70 +1188,20 @@ export class QmdMemoryManager implements MemorySearchManager {
args: string[], args: string[],
opts?: { timeoutMs?: number; discardOutput?: boolean }, opts?: { timeoutMs?: number; discardOutput?: boolean },
): Promise<{ stdout: string; stderr: string }> { ): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => { return await runCliCommand({
const spawnInvocation = resolveSpawnInvocation({ commandSummary: `qmd ${args.join(" ")}`,
spawnInvocation: resolveCliSpawnInvocation({
command: this.qmd.command, command: this.qmd.command,
args, args,
env: this.env, env: this.env,
packageName: "qmd", packageName: "qmd",
}); }),
const child = spawn(spawnInvocation.command, spawnInvocation.argv, { env: this.env,
env: this.env, cwd: this.workspaceDir,
cwd: this.workspaceDir, timeoutMs: opts?.timeoutMs,
shell: spawnInvocation.shell, maxOutputChars: this.maxQmdOutputChars,
windowsHide: spawnInvocation.windowsHide, // Large `qmd update` runs can easily exceed the output cap; keep only stderr.
}); discardStdout: opts?.discardOutput,
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
// When discardOutput is set, skip stdout accumulation entirely and keep
// only a small stderr tail for diagnostics -- never fail on truncation.
// This prevents large `qmd update` runs from hitting the output cap.
const discard = opts?.discardOutput === true;
const timer = opts?.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`qmd ${args.join(" ")} timed out after ${opts.timeoutMs}ms`));
}, opts.timeoutMs)
: null;
child.stdout.on("data", (data) => {
if (discard) {
return; // drain without accumulating
}
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 (!discard && (stdoutTruncated || stderrTruncated)) {
reject(
new Error(
`qmd ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`,
),
);
return;
}
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`qmd ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`));
}
});
}); });
} }
@@ -1347,62 +1250,17 @@ export class QmdMemoryManager implements MemorySearchManager {
shell?: boolean; shell?: boolean;
windowsHide?: boolean; windowsHide?: boolean;
}): Promise<{ stdout: string; stderr: string }> => }): Promise<{ stdout: string; stderr: string }> =>
await new Promise((resolve, reject) => { await runCliCommand({
const commandSummary = `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`; commandSummary: `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`,
const child = spawn(spawnInvocation.command, spawnInvocation.argv, { spawnInvocation,
// Keep mcporter and direct qmd commands on the same agent-scoped XDG state. // Keep mcporter and direct qmd commands on the same agent-scoped XDG state.
env: this.env, env: this.env,
cwd: this.workspaceDir, cwd: this.workspaceDir,
shell: spawnInvocation.shell, timeoutMs: opts?.timeoutMs,
windowsHide: spawnInvocation.windowsHide, maxOutputChars: this.maxQmdOutputChars,
});
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({ const primaryInvocation = resolveCliSpawnInvocation({
command: "mcporter", command: "mcporter",
args, args,
env: this.env, env: this.env,
@@ -1411,7 +1269,13 @@ export class QmdMemoryManager implements MemorySearchManager {
try { try {
return await runWithInvocation(primaryInvocation); return await runWithInvocation(primaryInvocation);
} catch (err) { } catch (err) {
if (!isWindowsCmdSpawnEinval(err, primaryInvocation.command)) { if (
!isWindowsCommandShimEinval({
err,
command: primaryInvocation.command,
commandBase: "mcporter",
})
) {
throw err; throw err;
} }
// Some Windows npm cmd shims can still throw EINVAL on spawn; retry through // Some Windows npm cmd shims can still throw EINVAL on spawn; retry through
@@ -2232,15 +2096,3 @@ export class QmdMemoryManager implements MemorySearchManager {
return [command, normalizedQuery, "--json", "-n", String(limit)]; return [command, normalizedQuery, "--json", "-n", String(limit)];
} }
} }
function appendOutputWithCap(
current: string,
chunk: string,
maxChars: number,
): { text: string; truncated: boolean } {
const appended = current + chunk;
if (appended.length <= maxChars) {
return { text: appended, truncated: false };
}
return { text: appended.slice(-maxChars), truncated: true };
}

144
src/memory/qmd-process.ts Normal file
View File

@@ -0,0 +1,144 @@
import { spawn } from "node:child_process";
import path from "node:path";
import {
materializeWindowsSpawnProgram,
resolveWindowsSpawnProgram,
} from "../plugin-sdk/windows-spawn.js";
export type CliSpawnInvocation = {
command: string;
argv: string[];
shell?: boolean;
windowsHide?: boolean;
};
function resolveWindowsCommandShim(command: string): string {
if (process.platform !== "win32") {
return command;
}
const trimmed = command.trim();
if (!trimmed) {
return command;
}
const ext = path.extname(trimmed).toLowerCase();
if (ext === ".cmd" || ext === ".exe" || ext === ".bat") {
return command;
}
const base = path.basename(trimmed).toLowerCase();
if (base === "qmd" || base === "mcporter") {
return `${trimmed}.cmd`;
}
return command;
}
export function resolveCliSpawnInvocation(params: {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
packageName: string;
}): CliSpawnInvocation {
const program = resolveWindowsSpawnProgram({
command: resolveWindowsCommandShim(params.command),
platform: process.platform,
env: params.env,
execPath: process.execPath,
packageName: params.packageName,
allowShellFallback: true,
});
return materializeWindowsSpawnProgram(program, params.args);
}
export function isWindowsCommandShimEinval(params: {
err: unknown;
command: string;
commandBase: string;
}): boolean {
if (process.platform !== "win32") {
return false;
}
const errno = params.err as NodeJS.ErrnoException | undefined;
if (errno?.code !== "EINVAL") {
return false;
}
const escapedBase = params.commandBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`(^|[\\\\/])${escapedBase}\\.cmd$`, "i").test(params.command);
}
export async function runCliCommand(params: {
commandSummary: string;
spawnInvocation: CliSpawnInvocation;
env: NodeJS.ProcessEnv;
cwd: string;
timeoutMs?: number;
maxOutputChars: number;
discardStdout?: boolean;
}): Promise<{ stdout: string; stderr: string }> {
return await new Promise((resolve, reject) => {
const child = spawn(params.spawnInvocation.command, params.spawnInvocation.argv, {
env: params.env,
cwd: params.cwd,
shell: params.spawnInvocation.shell,
windowsHide: params.spawnInvocation.windowsHide,
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
const discardStdout = params.discardStdout === true;
const timer = params.timeoutMs
? setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`${params.commandSummary} timed out after ${params.timeoutMs}ms`));
}, params.timeoutMs)
: null;
child.stdout.on("data", (data) => {
if (discardStdout) {
return;
}
const next = appendOutputWithCap(stdout, data.toString("utf8"), params.maxOutputChars);
stdout = next.text;
stdoutTruncated = stdoutTruncated || next.truncated;
});
child.stderr.on("data", (data) => {
const next = appendOutputWithCap(stderr, data.toString("utf8"), params.maxOutputChars);
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 (!discardStdout && (stdoutTruncated || stderrTruncated)) {
reject(
new Error(
`${params.commandSummary} produced too much output (limit ${params.maxOutputChars} chars)`,
),
);
return;
}
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(`${params.commandSummary} failed (code ${code}): ${stderr || stdout}`));
}
});
});
}
function appendOutputWithCap(
current: string,
chunk: string,
maxChars: number,
): { text: string; truncated: boolean } {
const appended = current + chunk;
if (appended.length <= maxChars) {
return { text: appended, truncated: false };
}
return { text: appended.slice(-maxChars), truncated: true };
}