fix(FN-1572): stabilize fusion agent execution
This commit is contained in:
@@ -69,6 +69,8 @@ export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
|
||||
export { AutomationStore } from "./automation-store.js";
|
||||
export type { AutomationStoreEvents } from "./automation-store.js";
|
||||
export { runCommandAsync } from "./run-command.js";
|
||||
export type { RunCommandOptions, RunCommandResult } from "./run-command.js";
|
||||
|
||||
// ── Routine System ───────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ensureQmdInstalled,
|
||||
qmdMemoryCollectionName,
|
||||
QMD_REFRESH_INTERVAL_MS,
|
||||
shouldSkipBackgroundQmdRefresh,
|
||||
} from "./memory-backend.js";
|
||||
import type { MemoryBackend } from "./memory-backend.js";
|
||||
|
||||
@@ -481,6 +482,10 @@ describe("memory-backend", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("skips background qmd refreshes under Vitest by default", () => {
|
||||
expect(shouldSkipBackgroundQmdRefresh()).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the OpenClaw qmd package install command", () => {
|
||||
expect(QMD_INSTALL_COMMAND).toBe("bun install -g @tobilu/qmd");
|
||||
});
|
||||
|
||||
@@ -36,6 +36,11 @@ type ExecFileAsync = (
|
||||
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
|
||||
let qmdInstallPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function shouldSkipBackgroundQmdRefresh(): boolean {
|
||||
return (process.env.VITEST === "true" || process.env.NODE_ENV === "test")
|
||||
&& process.env.FUSION_ENABLE_QMD_REFRESH_IN_TESTS !== "1";
|
||||
}
|
||||
|
||||
// ── Type Definitions ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -966,6 +971,10 @@ export async function refreshQmdProjectMemoryIndex(
|
||||
}
|
||||
|
||||
export function scheduleQmdProjectMemoryRefresh(rootDir: string): void {
|
||||
if (shouldSkipBackgroundQmdRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshQmdProjectMemoryIndex(rootDir).catch(() => {
|
||||
// qmd is optional. Search falls back to local file scanning when refresh fails.
|
||||
});
|
||||
@@ -1029,6 +1038,10 @@ export async function ensureQmdInstalledAndRefresh(rootDir: string): Promise<voi
|
||||
}
|
||||
|
||||
export function scheduleQmdInstallAndRefresh(rootDir: string): void {
|
||||
if (shouldSkipBackgroundQmdRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void ensureQmdInstalledAndRefresh(rootDir).catch(() => {
|
||||
// qmd remains optional at runtime. Search falls back to local file scanning.
|
||||
});
|
||||
|
||||
46
packages/core/src/run-command.test.ts
Normal file
46
packages/core/src/run-command.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { runCommandAsync } from "./run-command.js";
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("runCommandAsync", () => {
|
||||
it("terminates background children left in the command process group", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const childScript = "setInterval(() => {}, 1000)";
|
||||
const parentScript = [
|
||||
"const { spawn } = require('node:child_process');",
|
||||
`const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`,
|
||||
"console.log(child.pid);",
|
||||
"child.unref();",
|
||||
].join(" ");
|
||||
|
||||
const result = await runCommandAsync(
|
||||
`${process.execPath} -e ${JSON.stringify(parentScript)}`,
|
||||
{ timeoutMs: 5_000 },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const leakedPid = Number.parseInt(result.stdout.trim(), 10);
|
||||
expect(Number.isFinite(leakedPid)).toBe(true);
|
||||
|
||||
for (let i = 0; i < 10 && isProcessAlive(leakedPid); i++) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
expect(isProcessAlive(leakedPid)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ export interface RunCommandResult {
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
const FORCE_KILL_DELAY_MS = 5_000;
|
||||
const NORMAL_CLEANUP_FORCE_KILL_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* Run a shell command without blocking the Node.js event loop.
|
||||
@@ -45,14 +47,38 @@ export function runCommandAsync(
|
||||
let stderr = "";
|
||||
let bufferExceeded = false;
|
||||
let timedOut = false;
|
||||
let forceKillTimer: NodeJS.Timeout | null = null;
|
||||
const useProcessGroup = process.platform !== "win32";
|
||||
|
||||
const child = spawn(command, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
detached: useProcessGroup,
|
||||
shell: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const signalProcessGroup = (signal: NodeJS.Signals): void => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
if (useProcessGroup) {
|
||||
process.kill(-child.pid, signal);
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
} catch {
|
||||
// The command may already have exited and cleaned up its process group.
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleForceKill = (delayMs: number): void => {
|
||||
if (forceKillTimer) return;
|
||||
forceKillTimer = setTimeout(() => {
|
||||
signalProcessGroup("SIGKILL");
|
||||
}, delayMs);
|
||||
forceKillTimer.unref();
|
||||
};
|
||||
|
||||
const append = (current: string, chunk: Buffer): string => {
|
||||
const s = chunk.toString("utf-8");
|
||||
if (current.length + s.length > maxBuffer) {
|
||||
@@ -73,17 +99,17 @@ export function runCommandAsync(
|
||||
const timer = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5_000).unref();
|
||||
signalProcessGroup("SIGTERM");
|
||||
scheduleForceKill(FORCE_KILL_DELAY_MS);
|
||||
}, options.timeoutMs)
|
||||
: null;
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
@@ -97,6 +123,16 @@ export function runCommandAsync(
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
}
|
||||
// A shell command can exit successfully while leaving background children
|
||||
// in its process group (for example test runners, qmd indexers, or dev
|
||||
// servers launched with `&`). Clean the group after every run so Fusion
|
||||
// agents do not leak processes beyond the command lifecycle.
|
||||
signalProcessGroup("SIGTERM");
|
||||
scheduleForceKill(NORMAL_CLEANUP_FORCE_KILL_DELAY_MS);
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
|
||||
Reference in New Issue
Block a user