426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
461 lines
12 KiB
TypeScript
461 lines
12 KiB
TypeScript
/**
|
|
* Process execution utilities with command queuing.
|
|
*
|
|
* Provides cross-platform child process execution with:
|
|
* - Windows command resolution (.cmd extension handling)
|
|
* - Timeout support
|
|
* - Lane-based command queuing for serialization
|
|
* - Signal forwarding between parent and child processes
|
|
*
|
|
* @module utils/processQueue
|
|
*/
|
|
|
|
import { type ChildProcess, execFile, spawn } from "node:child_process";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { promisify } from "node:util";
|
|
import { sanitizeSpawnEnv } from "@elizaos/core";
|
|
import { resolveRuntimeExecutionMode } from "@elizaos/shared";
|
|
|
|
// ============================================================================
|
|
// Command Lanes
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Command execution lanes for organizing concurrent command execution.
|
|
* Lanes allow serialization within a lane while permitting parallelism across lanes.
|
|
*/
|
|
export const CommandLane = {
|
|
Main: "main",
|
|
Cron: "cron",
|
|
Subagent: "subagent",
|
|
Nested: "nested",
|
|
} as const;
|
|
|
|
export type CommandLane = (typeof CommandLane)[keyof typeof CommandLane];
|
|
|
|
// ============================================================================
|
|
// Process Execution
|
|
// ============================================================================
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* Resolves a command for Windows compatibility.
|
|
* On Windows, non-.exe commands (like npm) require their .cmd extension.
|
|
*/
|
|
function resolveCommand(command: string): string {
|
|
if (process.platform !== "win32") {
|
|
return command;
|
|
}
|
|
const basename = path.basename(command).toLowerCase();
|
|
const ext = path.extname(basename);
|
|
if (ext) {
|
|
return command;
|
|
}
|
|
const cmdCommands = ["npm", "yarn", "npx"];
|
|
if (cmdCommands.includes(basename)) {
|
|
return `${command}.cmd`;
|
|
}
|
|
return command;
|
|
}
|
|
|
|
function resolveCommandStdio(params: {
|
|
hasInput: boolean;
|
|
preferInherit: boolean;
|
|
}): ["pipe" | "inherit" | "ignore", "pipe", "pipe"] {
|
|
const stdin = params.hasInput ? "pipe" : params.preferInherit ? "inherit" : "pipe";
|
|
return [stdin, "pipe", "pipe"];
|
|
}
|
|
|
|
/**
|
|
* Simple promise-wrapped execFile with timeout support.
|
|
*
|
|
* @param command - The command to execute
|
|
* @param args - Command arguments
|
|
* @param opts - Timeout in ms or options object
|
|
* @returns Promise resolving to stdout and stderr
|
|
*/
|
|
export async function runExec(
|
|
command: string,
|
|
args: string[],
|
|
opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
const options =
|
|
typeof opts === "number"
|
|
? { timeout: opts, encoding: "utf8" as const }
|
|
: {
|
|
timeout: opts.timeoutMs,
|
|
maxBuffer: opts.maxBuffer,
|
|
encoding: "utf8" as const,
|
|
};
|
|
const { stdout, stderr } = await execFileAsync(resolveCommand(command), args, options);
|
|
return { stdout, stderr };
|
|
}
|
|
|
|
/**
|
|
* Result from a spawned command execution.
|
|
*/
|
|
export type SpawnResult = {
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number | null;
|
|
signal: NodeJS.Signals | null;
|
|
killed: boolean;
|
|
};
|
|
|
|
/**
|
|
* Options for running a command with timeout.
|
|
*/
|
|
export type CommandOptions = {
|
|
timeoutMs: number;
|
|
cwd?: string;
|
|
input?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
windowsVerbatimArguments?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Run a command with timeout, input, and environment control.
|
|
*
|
|
* @param argv - Command and arguments as array
|
|
* @param optionsOrTimeout - Options object or timeout in ms
|
|
* @returns Promise resolving to spawn result
|
|
*/
|
|
export async function runCommandWithTimeout(
|
|
argv: string[],
|
|
optionsOrTimeout: number | CommandOptions
|
|
): Promise<SpawnResult> {
|
|
const mode = resolveRuntimeExecutionMode();
|
|
if (mode === "cloud") {
|
|
throw new Error("Local shell execution disabled in cloud mode.");
|
|
}
|
|
if (mode === "local-safe") {
|
|
throw new Error(
|
|
"[shell] runCommandWithTimeout cannot route through SandboxManager from this code path; use the runtime-aware shell action."
|
|
);
|
|
}
|
|
|
|
const options: CommandOptions =
|
|
typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout;
|
|
const { timeoutMs, cwd, input, env } = options;
|
|
const { windowsVerbatimArguments } = options;
|
|
const hasInput = input !== undefined;
|
|
|
|
const shouldSuppressNpmFund = (() => {
|
|
const cmd = path.basename(argv[0] ?? "");
|
|
if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") {
|
|
return true;
|
|
}
|
|
if (cmd === "node" || cmd === "node.exe") {
|
|
const script = argv[1] ?? "";
|
|
return script.includes("npm-cli.js");
|
|
}
|
|
return false;
|
|
})();
|
|
|
|
const merged = env ? { ...process.env, ...env } : { ...process.env };
|
|
const resolvedEnv = sanitizeSpawnEnv(merged) as Record<string, string | undefined>;
|
|
if (shouldSuppressNpmFund) {
|
|
if (resolvedEnv.NPM_CONFIG_FUND == null) {
|
|
resolvedEnv.NPM_CONFIG_FUND = "false";
|
|
}
|
|
if (resolvedEnv.npm_config_fund == null) {
|
|
resolvedEnv.npm_config_fund = "false";
|
|
}
|
|
}
|
|
|
|
const stdio = resolveCommandStdio({ hasInput, preferInherit: true });
|
|
const child = spawn(resolveCommand(argv[0]), argv.slice(1), {
|
|
stdio,
|
|
cwd,
|
|
env: resolvedEnv,
|
|
windowsVerbatimArguments,
|
|
});
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (typeof child.kill === "function") {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}, timeoutMs);
|
|
|
|
if (hasInput && child.stdin) {
|
|
child.stdin.write(input);
|
|
child.stdin.end();
|
|
}
|
|
|
|
child.stdout?.on("data", (d) => {
|
|
stdout += d.toString();
|
|
});
|
|
child.stderr?.on("data", (d) => {
|
|
stderr += d.toString();
|
|
});
|
|
child.on("error", (err) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
child.on("close", (code, signal) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ stdout, stderr, code, signal, killed: child.killed });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Command Queue
|
|
// ============================================================================
|
|
|
|
type QueueEntry = {
|
|
task: () => Promise<unknown>;
|
|
resolve: (value: unknown) => void;
|
|
reject: (reason?: unknown) => void;
|
|
enqueuedAt: number;
|
|
warnAfterMs: number;
|
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
|
};
|
|
|
|
type LaneState = {
|
|
lane: string;
|
|
queue: QueueEntry[];
|
|
active: number;
|
|
maxConcurrent: number;
|
|
draining: boolean;
|
|
};
|
|
|
|
const lanes = new Map<string, LaneState>();
|
|
|
|
function getLaneState(lane: string): LaneState {
|
|
const existing = lanes.get(lane);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const created: LaneState = {
|
|
lane,
|
|
queue: [],
|
|
active: 0,
|
|
maxConcurrent: 1,
|
|
draining: false,
|
|
};
|
|
lanes.set(lane, created);
|
|
return created;
|
|
}
|
|
|
|
function drainLane(lane: string) {
|
|
const state = getLaneState(lane);
|
|
if (state.draining) {
|
|
return;
|
|
}
|
|
state.draining = true;
|
|
|
|
const pump = () => {
|
|
while (state.active < state.maxConcurrent && state.queue.length > 0) {
|
|
const entry = state.queue.shift() as QueueEntry;
|
|
const waitedMs = Date.now() - entry.enqueuedAt;
|
|
if (waitedMs >= entry.warnAfterMs) {
|
|
entry.onWait?.(waitedMs, state.queue.length);
|
|
}
|
|
state.active += 1;
|
|
void (async () => {
|
|
try {
|
|
const result = await entry.task();
|
|
state.active -= 1;
|
|
pump();
|
|
entry.resolve(result);
|
|
} catch (err) {
|
|
// error-policy:J1 queue-worker boundary; the task failure is
|
|
// propagated to the awaiting caller via entry.reject (never
|
|
// swallowed), and the active count is decremented so the pump drains.
|
|
state.active -= 1;
|
|
pump();
|
|
entry.reject(err);
|
|
}
|
|
})();
|
|
}
|
|
state.draining = false;
|
|
};
|
|
|
|
pump();
|
|
}
|
|
|
|
/**
|
|
* Set the maximum concurrency for a command lane.
|
|
*
|
|
* @param lane - The lane name
|
|
* @param maxConcurrent - Maximum concurrent tasks
|
|
*/
|
|
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
|
|
const cleaned = lane.trim() || CommandLane.Main;
|
|
const state = getLaneState(cleaned);
|
|
state.maxConcurrent = Math.max(1, Math.floor(maxConcurrent));
|
|
drainLane(cleaned);
|
|
}
|
|
|
|
/**
|
|
* Enqueue a task to execute in a specific lane.
|
|
*
|
|
* @param lane - The lane name
|
|
* @param task - Async task function
|
|
* @param opts - Options including warning threshold and callbacks
|
|
* @returns Promise resolving when task completes
|
|
*/
|
|
export function enqueueCommandInLane<T>(
|
|
lane: string,
|
|
task: () => Promise<T>,
|
|
opts?: {
|
|
warnAfterMs?: number;
|
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
|
}
|
|
): Promise<T> {
|
|
const cleaned = lane.trim() || CommandLane.Main;
|
|
const warnAfterMs = opts?.warnAfterMs ?? 2_000;
|
|
const state = getLaneState(cleaned);
|
|
return new Promise<T>((resolve, reject) => {
|
|
state.queue.push({
|
|
task: () => task(),
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
enqueuedAt: Date.now(),
|
|
warnAfterMs,
|
|
onWait: opts?.onWait,
|
|
});
|
|
drainLane(cleaned);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enqueue a task to execute in the main lane.
|
|
*
|
|
* @param task - Async task function
|
|
* @param opts - Options including warning threshold and callbacks
|
|
* @returns Promise resolving when task completes
|
|
*/
|
|
export function enqueueCommand<T>(
|
|
task: () => Promise<T>,
|
|
opts?: {
|
|
warnAfterMs?: number;
|
|
onWait?: (waitMs: number, queuedAhead: number) => void;
|
|
}
|
|
): Promise<T> {
|
|
return enqueueCommandInLane(CommandLane.Main, task, opts);
|
|
}
|
|
|
|
/**
|
|
* Get the current queue size for a lane (queued + active).
|
|
*
|
|
* @param lane - The lane name (defaults to main)
|
|
* @returns Queue size
|
|
*/
|
|
export function getQueueSize(lane: string = CommandLane.Main): number {
|
|
const resolved = lane.trim() || CommandLane.Main;
|
|
const state = lanes.get(resolved);
|
|
if (!state) {
|
|
return 0;
|
|
}
|
|
return state.queue.length + state.active;
|
|
}
|
|
|
|
/**
|
|
* Get total queue size across all lanes.
|
|
*
|
|
* @returns Total queue size
|
|
*/
|
|
export function getTotalQueueSize(): number {
|
|
let total = 0;
|
|
for (const s of lanes.values()) {
|
|
total += s.queue.length + s.active;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Clear all pending tasks in a lane.
|
|
*
|
|
* @param lane - The lane name (defaults to main)
|
|
* @returns Number of tasks cleared
|
|
*/
|
|
export function clearCommandLane(lane: string = CommandLane.Main): number {
|
|
const cleaned = lane.trim() || CommandLane.Main;
|
|
const state = lanes.get(cleaned);
|
|
if (!state) {
|
|
return 0;
|
|
}
|
|
const removed = state.queue.length;
|
|
state.queue.length = 0;
|
|
return removed;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Child Process Bridge
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Options for attaching a child process signal bridge.
|
|
*/
|
|
export type ChildProcessBridgeOptions = {
|
|
signals?: NodeJS.Signals[];
|
|
onSignal?: (signal: NodeJS.Signals) => void;
|
|
};
|
|
|
|
const defaultSignals: NodeJS.Signals[] =
|
|
process.platform === "win32"
|
|
? ["SIGTERM", "SIGINT", "SIGBREAK"]
|
|
: ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"];
|
|
|
|
/**
|
|
* Attach signal forwarding from parent to child process.
|
|
* Signals sent to the parent will be forwarded to the child.
|
|
* Automatically detaches when child exits.
|
|
*
|
|
* @param child - The child process
|
|
* @param options - Bridge options
|
|
* @returns Object with detach function
|
|
*/
|
|
export function attachChildProcessBridge(
|
|
child: ChildProcess,
|
|
{ signals = defaultSignals, onSignal }: ChildProcessBridgeOptions = {}
|
|
): { detach: () => void } {
|
|
const listeners = new Map<NodeJS.Signals, () => void>();
|
|
for (const signal of signals) {
|
|
const listener = (): void => {
|
|
onSignal?.(signal);
|
|
try {
|
|
child.kill(signal);
|
|
} catch {
|
|
// error-policy:J6 best-effort signal forwarding; the child may have
|
|
// already exited, so a failed kill is a no-op.
|
|
}
|
|
};
|
|
try {
|
|
process.on(signal, listener);
|
|
listeners.set(signal, listener);
|
|
} catch {
|
|
// error-policy:J6 the signal is unsupported on this platform (e.g. SIGHUP
|
|
// on Windows); skipping its listener is the designed degrade.
|
|
}
|
|
}
|
|
|
|
const detach = (): void => {
|
|
for (const [signal, listener] of listeners) {
|
|
process.off(signal, listener);
|
|
}
|
|
listeners.clear();
|
|
};
|
|
|
|
child.once("exit", detach);
|
|
child.once("error", detach);
|
|
|
|
return { detach };
|
|
}
|