Files
elizaos--eliza/packages/app-core/scripts/lib/api-supervisor.mjs
T
wehub-resource-sync 426e9eeabd
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
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

185 lines
6.3 KiB
JavaScript

/**
* API process supervisor with crash-loop backoff.
*
* Both `dev-ui.mjs` and `dev-platform.mjs` need to keep the API server alive
* across `process.exit(0)` (RESTART action), `process.exit(75)` (CLI runner
* restart exit code), and intentional reloads — but stop trying when the server
* keeps exiting in a tight window (a real crash that needs a human).
*
* The supervisor is unaware of how the API is spawned; callers pass a
* `spawnChild()` factory that returns a node ChildProcess. The factory is
* called once per launch, including relaunches.
*
* `restart()` lets a source watcher bounce the running child for a hot reload:
* the kill it triggers is classified as intentional, so it relaunches
* immediately and never counts toward the crash streak.
*
* Defaults:
* - 10s rolling window
* - 5 restarts permitted in that window before giving up
* - 400ms delay between exit and relaunch
*/
const DEFAULT_WINDOW_MS = 10_000;
const DEFAULT_LIMIT = 5;
const DEFAULT_RESPAWN_DELAY_MS = 400;
const DEFAULT_KILL_ESCALATE_MS = 4_000;
/**
* @typedef {Object} ApiSupervisorOptions
* @property {() => import("node:child_process").ChildProcess} spawnChild
* Spawn a fresh API child. Called on `start()` and on every relaunch.
* @property {(child: import("node:child_process").ChildProcess) => void} [onSpawn]
* Optional callback after each spawn (e.g. push child into a tracking array,
* wire log prefixers).
* @property {(child: import("node:child_process").ChildProcess) => void} [onExit]
* Optional callback before backoff/relaunch decision (e.g. remove child
* from a tracking array, clear handle).
* @property {(code: number | null, streak: number) => void} onGiveUp
* Called when the streak exceeds `limit` in `windowMs`. Caller should
* trigger shutdown.
* @property {() => boolean} isShuttingDown
* Returns true while the parent process is in shutdown. Suppresses relaunch.
* @property {(child: import("node:child_process").ChildProcess, signal: "SIGTERM" | "SIGKILL") => void} [terminate]
* How to kill a child for an intentional `restart()`. Defaults to
* `child.kill(signal)`; callers that spawn process trees pass a tree-killer.
* @property {(message: string) => void} [log] Defaults to `console.log`.
* @property {(message: string) => void} [warn] Defaults to `console.error`.
* @property {number} [windowMs] Default 10_000.
* @property {number} [limit] Default 5.
* @property {number} [respawnDelayMs] Default 400.
*/
/**
* @param {ApiSupervisorOptions} opts
*/
export function createApiSupervisor(opts) {
const {
spawnChild,
onSpawn,
onExit,
onGiveUp,
isShuttingDown,
terminate = (child, signal) => {
try {
child.kill(signal);
} catch {
// child already gone — nothing to signal.
}
},
log = console.log.bind(console),
warn = console.error.bind(console),
windowMs = DEFAULT_WINDOW_MS,
limit = DEFAULT_LIMIT,
respawnDelayMs = DEFAULT_RESPAWN_DELAY_MS,
} = opts;
let streak = 0;
let lastExitAt = 0;
/** @type {ReturnType<typeof setTimeout> | null} */
let pendingRespawn = null;
/** @type {import("node:child_process").ChildProcess | null} */
let currentChild = null;
/** When true, the next exit is a hot reload, not a crash. */
let intentionalRestart = false;
/** @type {ReturnType<typeof setTimeout> | null} */
let killEscalation = null;
function clearKillEscalation() {
if (killEscalation) {
clearTimeout(killEscalation);
killEscalation = null;
}
}
function scheduleRelaunch() {
pendingRespawn = setTimeout(() => {
pendingRespawn = null;
if (!isShuttingDown()) launch();
}, respawnDelayMs);
}
function launch() {
const child = spawnChild();
currentChild = child;
if (onSpawn) onSpawn(child);
child.on("exit", (code) => {
if (onExit) onExit(child);
if (child === currentChild) currentChild = null;
clearKillEscalation();
if (isShuttingDown()) return;
// Intentional hot reload (a source change). Relaunch promptly without
// touching the crash streak — this is expected, not a failure.
if (intentionalRestart) {
intentionalRestart = false;
scheduleRelaunch();
return;
}
const now = Date.now();
streak = now - lastExitAt < windowMs ? streak + 1 : 1;
lastExitAt = now;
if (streak > limit) {
warn(
`API exited with code ${code} ${streak} times in ${
windowMs / 1000
}s — giving up. Fix the underlying issue and restart the dev process.`,
);
onGiveUp(code, streak);
return;
}
// The agent's RESTART action and `/api/restart` both bounce the server
// with `process.exit(0)`; the CLI runner uses 75 as the dedicated
// restart exit code. Treat any non-shutdown exit as "please restart me".
log(
`API exited with code ${code} — relaunching (attempt ${streak}/${limit})…`,
);
scheduleRelaunch();
});
return child;
}
return {
start() {
return launch();
},
/**
* Bounce the running API child for a hot reload. No-op during shutdown.
* Safe to call repeatedly (e.g. from a debounced watcher) — overlapping
* calls collapse onto the one in-flight kill.
*/
restart() {
if (isShuttingDown()) return;
const child = currentChild;
if (!child) {
// Already between processes — a relaunch is queued (or will be). Make
// sure one is actually pending so a restart() in the gap isn't lost.
if (!pendingRespawn) launch();
return;
}
// An intentional reload is not a crash — reset the streak so a burst of
// edits can never trip the crash-loop give-up.
streak = 0;
if (intentionalRestart) return; // a kill is already in flight
intentionalRestart = true;
terminate(child, "SIGTERM");
killEscalation = setTimeout(() => {
killEscalation = null;
if (currentChild === child) terminate(child, "SIGKILL");
}, DEFAULT_KILL_ESCALATE_MS);
killEscalation.unref?.();
},
/** Cancel any pending relaunch / kill escalation (e.g. during shutdown). */
cancelPendingRespawn() {
if (pendingRespawn) {
clearTimeout(pendingRespawn);
pendingRespawn = null;
}
clearKillEscalation();
},
};
}