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
185 lines
6.3 KiB
JavaScript
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();
|
|
},
|
|
};
|
|
}
|