Files
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

1937 lines
73 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S npx tsx
/**
* Standalone provisioning worker.
*
* The Cloudflare cron route only triggers the Node sidecar because provisioning
* pulls in Node-only SSH/Docker modules. This daemon runs on that sidecar and
* delegates to the same ProvisioningJobService used by the API, so enqueue,
* claim, retry, sandbox status, webhooks, and health checks share one codepath.
*
* Usage:
* npx tsx packages/scripts/daemons/provisioning-worker.ts
* npx tsx packages/scripts/daemons/provisioning-worker.ts --once
*/
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type { AppOrphanReconcileResult } from "@elizaos/cloud-shared/lib/services/app-container-orphan-reconciler";
import type { OrphanReconcileResult } from "@elizaos/cloud-shared/lib/services/docker-node-workloads";
import {
type ProvisioningJobType,
resolveJobTypesForLanes,
} from "@elizaos/cloud-shared/lib/services/provisioning-job-types";
import type {
HeartbeatResult,
ProcessingResult,
RecoveryResult,
} from "@elizaos/cloud-shared/lib/services/provisioning-jobs";
import { loadLocalEnv } from "./shared/load-env";
type WorkerLogger =
typeof import("@elizaos/cloud-shared/lib/utils/logger").logger;
type WorkerService =
typeof import("@elizaos/cloud-shared/lib/services/provisioning-jobs").provisioningJobService;
type WorkerNodeManager =
typeof import("@elizaos/cloud-shared/lib/services/docker-node-manager").dockerNodeManager;
type WorkerNodeAutoscaler =
typeof import("@elizaos/cloud-shared/lib/services/containers/node-autoscaler").getNodeAutoscaler;
type WorkerWarmPoolManager =
typeof import("@elizaos/cloud-shared/lib/services/containers/agent-warm-pool").WarmPoolManager;
type WorkerContainersEnv =
typeof import("@elizaos/cloud-shared/lib/config/containers-env").containersEnv;
type WorkerAssertSSHKeyAvailable =
typeof import("@elizaos/cloud-shared/lib/config/containers-env").assertSSHKeyAvailable;
type WorkerWarmPoolCreator =
typeof import("@elizaos/cloud-shared/lib/services/containers/agent-warm-pool-creator").getHetznerPoolContainerCreator;
type WorkerResolveImageDigest =
typeof import("@elizaos/cloud-shared/lib/services/containers/registry-probe").resolveImageDigest;
type WorkerAgentSandboxesRepository =
typeof import("@elizaos/cloud-shared/db/repositories/agent-sandboxes").agentSandboxesRepository;
type WorkerJobsRepository =
typeof import("@elizaos/cloud-shared/db/repositories/jobs").jobsRepository;
type WorkerReconcileOrphanContainers =
typeof import("@elizaos/cloud-shared/lib/services/docker-node-workloads").reconcileOrphanContainersOnNodes;
type WorkerReconcileOrphanAppContainers =
typeof import("@elizaos/cloud-shared/lib/services/app-container-orphan-reconciler").reconcileOrphanAppContainersOnNodes;
type WorkerWithTimeout =
typeof import("@elizaos/cloud-shared/lib/utils/with-timeout").withTimeout;
type WorkerProcessNodeDiskCleanup =
typeof import("@elizaos/cloud-shared/lib/services/node-disk-manager").processNodeDiskCleanup;
type WorkerRunBackupVerificationCycle =
typeof import("@elizaos/cloud-shared/lib/services/agent-backup-verifier").runBackupVerificationCycle;
interface PreflightKmsClient {
getOrCreateKey(keyId: string): Promise<unknown>;
}
type PreflightCreateKmsClient = (opts: {
env: NodeJS.ProcessEnv;
}) => PreflightKmsClient;
type PreflightResolveKmsBackend = (opts: {
env: NodeJS.ProcessEnv;
}) => "memory" | "local" | "steward";
interface WorkerDeps {
logger: WorkerLogger;
provisioningJobService: WorkerService;
dockerNodeManager: WorkerNodeManager;
getNodeAutoscaler: WorkerNodeAutoscaler;
WarmPoolManager: WorkerWarmPoolManager;
getHetznerPoolContainerCreator: WorkerWarmPoolCreator;
containersEnv: WorkerContainersEnv;
assertSSHKeyAvailable: WorkerAssertSSHKeyAvailable;
resolveImageDigest: WorkerResolveImageDigest;
agentSandboxesRepository: WorkerAgentSandboxesRepository;
jobsRepository: WorkerJobsRepository;
reconcileOrphanContainersOnNodes: WorkerReconcileOrphanContainers;
reconcileOrphanAppContainersOnNodes: WorkerReconcileOrphanAppContainers;
withTimeout: WorkerWithTimeout;
processNodeDiskCleanup: WorkerProcessNodeDiskCleanup;
runBackupVerificationCycle: WorkerRunBackupVerificationCycle;
}
export interface ProvisioningWorkerConfig {
pollIntervalMs: number;
batchSize: number;
runOnce: boolean;
nodeHealthIntervalMs: number;
/**
* Job types this daemon claims, from `PROVISIONING_JOB_LANES`. Unset → ALL
* (the single-daemon default; behavior unchanged). Pin to `agent` once a
* dedicated apps-control daemon owns the `apps` lane so this control-plane
* worker stops claiming (and failing) APP_DEPLOY/CONTAINER_* jobs it can't
* reach the private tenant DB to run.
*/
jobTypes: readonly ProvisioningJobType[];
/**
* When true (default), a watchdog-wedged worker exits with code 1 after
* `watchdogConsecutiveTicks` consecutive stale heartbeat ticks so systemd
* `Restart=always` relaunches it. Gated by `PROVISIONING_WORKER_SELF_RESTART`
* (set to `0`/`false` to disable, e.g. during planned infra maintenance).
*/
selfRestartEnabled: boolean;
/**
* Number of consecutive heartbeat ticks the watchdog must stay tripped before
* the worker self-restarts. The watchdog trips at WATCHDOG_MAX_CYCLE_MS of no
* completed cycle, and restart fires on the K-th consecutive tick after, so
* the time from the last good cycle to exit(1) is
* ≈ WATCHDOG_MAX_CYCLE_MS + (K-1) * HEARTBEAT_INTERVAL_MS (≈315s at K=2). The
* extra ticks keep a one-off slow cycle from restart-looping; they are NOT a
* "~2x window" multiplier.
*/
watchdogConsecutiveTicks: number;
/**
* When true, the low-cadence orphan-container reconcilers sweep HEALTHY nodes
* for leaked containers with no live DB row (or a terminal-state row) and
* force-remove them — both the agent sweep (`agent-<id>` vs `agent_sandboxes`)
* and the apps sweep (`app-<slug>` vs `containers`). Gated OFF by default via
* `ORPHAN_RECONCILER_ENABLED=1` because they issue `docker rm -f` and should be
* armed deliberately.
*/
orphanReconcilerEnabled: boolean;
/**
* DB liveness threshold (#15160): when the newest `jobs` row is older than
* this many hours, the worker logs a loud error naming the DB host —
* "this database looks abandoned; check DATABASE_URL points where the API
* writes". Warn-only, never fatal: an idle env legitimately has old jobs.
* Tunable via `CONTAINERS_DB_LIVENESS_MAX_AGE_HOURS` (default 24).
*/
dbLivenessMaxAgeHours: number;
}
const DEFAULT_POLL_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 3;
/**
* Node health-check cadence. 5 minutes matches the `agent-hot-pool`
* CRON_FANOUT schedule. SSH uses `CONTAINERS_SSH_KEY` from this host.
*/
const DEFAULT_NODE_HEALTH_INTERVAL_MS = 5 * 60_000;
/**
* Default consecutive-tick threshold for the self-restart watchdog. At K=2 with
* the 15s heartbeat tick and the 5min watchdog window, the worker restarts
* ≈ WATCHDOG_MAX_CYCLE_MS + (K-1) * HEARTBEAT_INTERVAL_MS ≈ 315s after the last
* good cycle (the watchdog window plus one extra 15s tick), not ~2x the window.
*/
const DEFAULT_WATCHDOG_CONSECUTIVE_TICKS = 2;
/**
* Default DB liveness threshold. 24h is far above any healthy env's
* inter-job gap (agent creates, upgrades, and heartbeat-driven retries all
* insert jobs) but small enough to flag an abandoned database within a day
* instead of the 3 weeks it took to notice #15160.
*/
const DEFAULT_DB_LIVENESS_MAX_AGE_HOURS = 24;
const workerStartedAt = new Date();
function parsePositiveInt(value: string | undefined, fallback: number): number {
if (!value) return fallback;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function hasFlag(argv: readonly string[], flag: string): boolean {
return argv.includes(flag);
}
/** Parse an opt-OUT boolean env flag: anything but `0`/`false` keeps the default-on. */
function parseBooleanDefaultTrue(value: string | undefined): boolean {
if (value === undefined) return true;
const normalized = value.trim().toLowerCase();
return normalized !== "0" && normalized !== "false";
}
export function readWorkerConfig(
env: NodeJS.ProcessEnv = process.env,
argv: readonly string[] = process.argv.slice(2),
): ProvisioningWorkerConfig {
return {
pollIntervalMs: parsePositiveInt(
env.WORKER_POLL_INTERVAL,
DEFAULT_POLL_INTERVAL_MS,
),
batchSize: parsePositiveInt(env.WORKER_BATCH_SIZE, DEFAULT_BATCH_SIZE),
runOnce: env.WORKER_RUN_ONCE === "1" || hasFlag(argv, "--once"),
nodeHealthIntervalMs: parsePositiveInt(
env.WORKER_NODE_HEALTH_INTERVAL,
DEFAULT_NODE_HEALTH_INTERVAL_MS,
),
jobTypes: resolveJobTypesForLanes(env.PROVISIONING_JOB_LANES),
selfRestartEnabled: parseBooleanDefaultTrue(
env.PROVISIONING_WORKER_SELF_RESTART,
),
watchdogConsecutiveTicks: parsePositiveInt(
env.PROVISIONING_WORKER_WATCHDOG_TICKS,
DEFAULT_WATCHDOG_CONSECUTIVE_TICKS,
),
orphanReconcilerEnabled: env.ORPHAN_RECONCILER_ENABLED === "1",
dbLivenessMaxAgeHours: parsePositiveInt(
env.CONTAINERS_DB_LIVENESS_MAX_AGE_HOURS,
DEFAULT_DB_LIVENESS_MAX_AGE_HOURS,
),
};
}
let depsPromise: Promise<WorkerDeps> | null = null;
/**
* Test-only seam: inject a pre-resolved deps bag so cycle functions can be
* exercised without the real dynamic imports (DB/SSH/Docker). Never called in
* production. Pass `null` to reset back to lazy real loading.
*/
export function __setDepsForTests(deps: WorkerDeps | null): void {
depsPromise = deps ? Promise.resolve(deps) : null;
if (!deps) cachedWarmPoolManagerInstance = null;
}
async function loadDeps(): Promise<WorkerDeps> {
if (!depsPromise) {
depsPromise = Promise.all([
import("@elizaos/cloud-shared/lib/services/provisioning-jobs"),
import("@elizaos/cloud-shared/lib/utils/logger"),
import("@elizaos/cloud-shared/lib/services/docker-node-manager"),
import("@elizaos/cloud-shared/lib/services/containers/node-autoscaler"),
import("@elizaos/cloud-shared/lib/services/containers/agent-warm-pool"),
import(
"@elizaos/cloud-shared/lib/services/containers/agent-warm-pool-creator"
),
import("@elizaos/cloud-shared/lib/config/containers-env"),
import("@elizaos/cloud-shared/lib/services/containers/registry-probe"),
import("@elizaos/cloud-shared/db/repositories/agent-sandboxes"),
import("@elizaos/cloud-shared/db/repositories/jobs"),
import("@elizaos/cloud-shared/lib/services/docker-node-workloads"),
import(
"@elizaos/cloud-shared/lib/services/app-container-orphan-reconciler"
),
import("@elizaos/cloud-shared/lib/utils/with-timeout"),
import("@elizaos/cloud-shared/lib/services/node-disk-manager"),
import("@elizaos/cloud-shared/lib/services/agent-backup-verifier"),
]).then(
([
jobsModule,
loggerModule,
nodeMgrModule,
autoscalerModule,
warmPoolModule,
warmPoolCreatorModule,
containersEnvModule,
registryProbeModule,
agentSandboxesModule,
jobsRepoModule,
nodeWorkloadsModule,
appOrphanReconcilerModule,
withTimeoutModule,
nodeDiskManagerModule,
backupVerifierModule,
]) => ({
provisioningJobService: jobsModule.provisioningJobService,
logger: loggerModule.logger,
dockerNodeManager: nodeMgrModule.dockerNodeManager,
getNodeAutoscaler: autoscalerModule.getNodeAutoscaler,
WarmPoolManager: warmPoolModule.WarmPoolManager,
getHetznerPoolContainerCreator:
warmPoolCreatorModule.getHetznerPoolContainerCreator,
containersEnv: containersEnvModule.containersEnv,
assertSSHKeyAvailable: containersEnvModule.assertSSHKeyAvailable,
resolveImageDigest: registryProbeModule.resolveImageDigest,
agentSandboxesRepository: agentSandboxesModule.agentSandboxesRepository,
jobsRepository: jobsRepoModule.jobsRepository,
reconcileOrphanContainersOnNodes:
nodeWorkloadsModule.reconcileOrphanContainersOnNodes,
reconcileOrphanAppContainersOnNodes:
appOrphanReconcilerModule.reconcileOrphanAppContainersOnNodes,
withTimeout: withTimeoutModule.withTimeout,
processNodeDiskCleanup: nodeDiskManagerModule.processNodeDiskCleanup,
runBackupVerificationCycle:
backupVerifierModule.runBackupVerificationCycle,
}),
);
}
return depsPromise;
}
let cachedWarmPoolManagerInstance: InstanceType<WorkerWarmPoolManager> | null =
null;
async function getWarmPoolManager(): Promise<
InstanceType<WorkerWarmPoolManager>
> {
if (cachedWarmPoolManagerInstance) return cachedWarmPoolManagerInstance;
const { WarmPoolManager, getHetznerPoolContainerCreator } = await loadDeps();
cachedWarmPoolManagerInstance = new WarmPoolManager(
getHetznerPoolContainerCreator(),
);
return cachedWarmPoolManagerInstance;
}
function resultContext(result: ProcessingResult): Record<string, unknown> {
return {
claimed: result.claimed,
succeeded: result.succeeded,
failed: result.failed,
errors: result.errors,
};
}
/**
* Flatten an error's `cause` chain into the logged message. Drizzle wraps
* query failures in `DrizzleQueryError` with the underlying pg error on
* `.cause`, so logging only `error.message` yields "Failed query: <SQL>…" and
* hides the actual failure (e.g. the TLS certificate rejection behind every
* cycle failure during a DB repoint). Depth-bounded so a pathological
* self-referencing chain can't loop. Exported for unit testing.
*/
export function formatErrorWithCause(error: unknown): string {
let message = error instanceof Error ? error.message : String(error);
let cause: unknown = error instanceof Error ? error.cause : undefined;
for (let depth = 0; cause !== undefined && depth < 5; depth++) {
message += `; caused by: ${cause instanceof Error ? cause.message : String(cause)}`;
cause = cause instanceof Error ? cause.cause : undefined;
}
return message;
}
/**
* One-shot latch so the active KMS backend is logged once at startup, not on
* every poll cycle (the preflight re-runs each cycle to gate the hb_signal).
* Reset only for tests.
*/
let kmsBackendLogged = false;
/** Reset the KMS-backend log latch (tests only). */
export function resetKmsBackendLogForTests(): void {
kmsBackendLogged = false;
}
/**
* Log the active KMS backend exactly once (#15310, behavior 1: "log the active
* KMS backend at startup"). Runtime callers pass the already-loaded worker
* logger; unit tests that exercise the pure preflight without a logger simply do
* not consume the one-shot latch.
*/
function logKmsBackendOnce(
backend: "memory" | "local" | "steward",
env: NodeJS.ProcessEnv,
logger?: Pick<WorkerLogger, "info" | "warn">,
): void {
if (!logger) return;
if (kmsBackendLogged) return;
kmsBackendLogged = true;
const nodeEnv = env.NODE_ENV ?? "<unset>";
const line = `[provisioning-worker] active KMS backend: ${backend} (NODE_ENV=${nodeEnv})`;
if (backend === "memory") logger.warn(line);
else logger.info(line);
}
/**
* A KMS backend is "durable" if it survives a worker restart. The `memory`
* backend does NOT: it derives a fresh per-process root key on every start, so
* every DEK / snapshot / API-key it encrypted is orphaned on the next restart
* (KeyNotFoundError, then `AEAD decrypt failed` on the old backup). That is the
* staging root cause behind #15310 — the worker ran `ELIZA_KMS_BACKEND=memory`,
* so every pre-upgrade snapshot was permanently undecryptable after a bounce.
*
* `memory` is legitimate only in NODE_ENV=test/development (each test process is
* its own throwaway world). Anywhere else — including staging, which the
* existing `getKmsClient()` guard MISSES because it gates on
* `isProductionDeployment` (staging is not "production") — a `memory` backend is
* a latent data-loss bomb. Exported for unit testing; pure so the decision is
* checkable without booting a KMS client.
*/
export function assertKmsBackendDurable(
backend: "memory" | "local" | "steward",
env: NodeJS.ProcessEnv,
): void {
if (backend !== "memory") return;
// Ephemeral `memory` is only ever acceptable in test/development, where each
// process is disposable. NODE_ENV unset (a bare daemon launch) is treated as
// NOT test/development — refuse, because a real deploy that forgot to set
// NODE_ENV is exactly the misconfig we are guarding against.
const nodeEnv = env.NODE_ENV;
if (nodeEnv === "test" || nodeEnv === "development") return;
throw new Error(
"memory KMS loses all org DEKs on restart; set ELIZA_KMS_BACKEND=local " +
"(with a persistent ELIZA_LOCAL_ROOT_KEY) or a durable backend. The " +
"ephemeral 'memory' backend rotates its root key on every process start, " +
"so every pre-upgrade snapshot, API key, and BYO secret it encrypts is " +
"permanently undecryptable after the next worker restart (this stranded " +
"real users on staging, #15310). It is only valid in " +
`NODE_ENV=test/development (got NODE_ENV=${nodeEnv ?? "<unset>"}).`,
);
}
export async function assertProvisioningWorkerPreflight(
opts: {
env?: NodeJS.ProcessEnv;
createKmsClient?: PreflightCreateKmsClient;
resolveKmsBackend?: PreflightResolveKmsBackend;
logger?: Pick<WorkerLogger, "info" | "warn">;
} = {},
): Promise<void> {
const env = opts.env ?? process.env;
let createKmsClient = opts.createKmsClient;
let resolveKmsBackend = opts.resolveKmsBackend;
if (!createKmsClient || !resolveKmsBackend) {
const kmsModule = await import("@elizaos/security/kms");
createKmsClient ??= kmsModule.createKmsClient;
resolveKmsBackend ??= (backendOpts) =>
kmsModule.resolveKmsBackend(backendOpts);
}
if (!createKmsClient || !resolveKmsBackend) {
throw new Error(
"Provisioning worker preflight dependencies were not initialized",
);
}
// Config preflight (#15310, behavior 1): resolve the active backend, log it
// once, and REFUSE to start on the ephemeral `memory` backend outside
// test/development BEFORE we ever touch a key. This runs ahead of the
// getOrCreateKey probe because `memory` would happily service that probe — it
// works fine in-process; the failure only manifests on the NEXT restart, by
// which point the backups are already lost. A loud, actionable, fail-fast is
// the only prevention.
const backend = resolveKmsBackend({ env });
logKmsBackendOnce(backend, env, opts.logger);
assertKmsBackendDurable(backend, env);
try {
const kms = createKmsClient({ env });
// Use the systemKey() helper so the key id matches the KEY_RE regex in
// packages/security/src/kms/key-namespace.ts (`/v<digit>` suffix required).
// Bare strings like "system:..." now throw `malformed key id` since the
// strict namespace regex landed in 0330ba3d64.
const { systemKey } = await import("@elizaos/security/kms");
await kms.getOrCreateKey(systemKey("provisioning-worker-preflight"));
} catch (error) {
const message = formatErrorWithCause(error);
throw new Error(
"Provisioning worker preflight failed: KMS is not usable. " +
"Refusing to publish a healthy heartbeat or claim provisioning jobs. " +
"Configure ELIZA_KMS_BACKEND=local with a persistent ELIZA_LOCAL_ROOT_KEY, " +
"or wire a working Steward KMS client. " +
`Cause: ${message}`,
);
}
}
export interface JobsTableLivenessAssessment {
/** True when the newest jobs row is older than the threshold, or the table is empty. */
stale: boolean;
/** Hours since the newest jobs row was created; null when the table is empty. */
ageHours: number | null;
maxAgeHours: number;
latestJobCreatedAt: Date | null;
}
/**
* Pure threshold decision behind the DB liveness check (#15160). The staging
* outage happened because this daemon silently polled a database the API had
* stopped writing to for 3 weeks — zero errors, just an eternally empty
* queue, while every agent create sat `pending` in ANOTHER database. A jobs
* table whose newest row is older than the threshold (or that is empty) is
* treated as stale: probably not the database the API writes to. A
* created_at in the future (clock skew) is fresh, not stale. Exported for
* unit testing, mirroring `evaluateSelfRestart`.
*/
export function evaluateJobsTableLiveness(deps: {
latestJobCreatedAt: Date | null;
maxAgeHours: number;
now?: Date;
}): JobsTableLivenessAssessment {
const { latestJobCreatedAt, maxAgeHours } = deps;
if (!latestJobCreatedAt) {
return {
stale: true,
ageHours: null,
maxAgeHours,
latestJobCreatedAt: null,
};
}
const now = deps.now ?? new Date();
const ageHours = (now.getTime() - latestJobCreatedAt.getTime()) / 3_600_000;
return {
stale: ageHours > maxAgeHours,
ageHours,
maxAgeHours,
latestJobCreatedAt,
};
}
/**
* Host portion of a database URL for log messages — never the credentials.
* `new URL` drops user:pass with `.host`; pglite:// URLs have no host, so
* fall back to the pathname (the local data dir). Exported for unit testing.
*/
export function databaseHostForLogs(databaseUrl: string | undefined): string {
if (!databaseUrl) return "<DATABASE_URL not set>";
try {
const parsed = new URL(databaseUrl);
return parsed.host || parsed.pathname || "<no host in DATABASE_URL>";
} catch {
return "<unparseable DATABASE_URL>";
}
}
/** Query side of the DB liveness check: newest jobs row → threshold decision. */
async function processDbLivenessCheckCycle(
config: ProvisioningWorkerConfig,
): Promise<JobsTableLivenessAssessment> {
const { jobsRepository } = await loadDeps();
const latestJobCreatedAt = await jobsRepository.findLatestCreatedAt();
return evaluateJobsTableLiveness({
latestJobCreatedAt,
maxAgeHours: config.dbLivenessMaxAgeHours,
});
}
/**
* WARN LOUDLY — level error, DB host named — when the jobs table looks
* abandoned, but never crash: an idle env legitimately has old jobs, so this
* is a signal for a human, not a fail-stop. Re-emitted on every infra
* maintenance sweep so the signal is unmissable in the logs, not a one-shot
* scrolled away at boot.
*/
function logJobsTableLiveness(
logger: WorkerLogger,
assessment: JobsTableLivenessAssessment,
): void {
if (!assessment.stale) return;
const databaseHost = databaseHostForLogs(process.env.DATABASE_URL);
const newestRow =
assessment.ageHours === null
? "the table is EMPTY"
: `the newest row is ${assessment.ageHours.toFixed(1)}h old`;
logger.error(
`[provisioning-worker] DB LIVENESS: the jobs table on ${databaseHost} looks abandoned — ` +
`${newestRow}, threshold ${assessment.maxAgeHours}h (CONTAINERS_DB_LIVENESS_MAX_AGE_HOURS). ` +
"Check that this daemon's DATABASE_URL points at the SAME database the cloud-api " +
"writes jobs to: a split pair polls an eternally empty queue with zero errors while " +
"every provision hangs pending forever (#15160). If this env is intentionally idle, " +
"raise the threshold.",
{
event: "worker.db_liveness_stale",
databaseHost,
latestJobCreatedAt: assessment.latestJobCreatedAt?.toISOString() ?? null,
ageHours: assessment.ageHours,
maxAgeHours: assessment.maxAgeHours,
},
);
}
async function processProvisioningWorkerCycle(
batchSize = readWorkerConfig().batchSize,
jobTypes?: readonly ProvisioningJobType[],
): Promise<ProcessingResult> {
const { provisioningJobService } = await loadDeps();
return provisioningJobService.processPendingJobs(batchSize, { jobTypes });
}
async function processHeartbeatCycle(
concurrency = 5,
): Promise<HeartbeatResult> {
const { provisioningJobService } = await loadDeps();
return provisioningJobService.processRunningHeartbeats(concurrency);
}
async function processRecoveryCycle(concurrency = 5): Promise<RecoveryResult> {
const { provisioningJobService } = await loadDeps();
return provisioningJobService.processDisconnectedRecovery(concurrency);
}
async function processStartupInterruptedJobsRecoveryCycle(
config: ProvisioningWorkerConfig,
): Promise<number> {
const { provisioningJobService } = await loadDeps();
return provisioningJobService.recoverInterruptedJobsOnStartup(
workerStartedAt,
config.jobTypes,
);
}
/**
* Self-heal rows WEDGED in `provisioning` whose container is actually healthy
* — the readiness-probe false-negative split-brain (#15310 failure mode #6).
* The Worker-side cleanup cron can only mark these `error` (no SSH); THIS
* daemon can re-probe the container node-side and flip it back to `running`.
*/
async function processStuckProvisioningReconcileCycle(): Promise<{
total: number;
recovered: number;
unresolved: number;
failed: number;
}> {
const { provisioningJobService } = await loadDeps();
return provisioningJobService.reconcileStuckProvisioning();
}
interface NodeHealthSummary {
total: number;
healthy: number;
unhealthy: number;
}
interface PrePullImagesSummary {
attempted: number;
failed: number;
}
interface NodeAutoscaleSummary {
action:
| "noop"
| "scale_up"
| "scale_down"
| "scale_up_skipped"
| "scale_up_failed"
| "drain_failed";
detail?: string;
}
interface PoolDrainSummary {
drained: number;
}
interface PoolReplenishSummary {
created: number;
failed: number;
reason: string;
}
/**
* Health-checks every enabled `docker_nodes` row (SSH + `docker info`) and
* persists the resulting status. Runs on the orchestrator host that already
* holds `CONTAINERS_SSH_KEY`, so the node-status truth lives next to the
* provisioner that acts on it.
*/
async function processNodeHealthCheckCycle(): Promise<NodeHealthSummary> {
const { dockerNodeManager } = await loadDeps();
const result = await dockerNodeManager.healthCheckAll();
let healthy = 0;
let unhealthy = 0;
for (const status of result.values()) {
if (status === "healthy") {
healthy += 1;
} else {
unhealthy += 1;
}
}
return { total: result.size, healthy, unhealthy };
}
interface NodeDiskCleanupSummary {
nodesScanned: number;
nodesSkipped: number;
pruned: number;
pruneFailed: number;
}
/**
* Prune Docker disk on HEALTHY nodes that crossed the high-water mark. Reclaims
* with `docker system prune -af` (no `--volumes`) + clears stuck containerd
* ingest from failed pulls + buildkit prune, with a per-node cooldown so it does
* not prune every tick. This is the missing self-management that keeps a node
* from filling up on retried failed image pulls (`no space left on device`) and
* breaking dedicated-agent provisioning. Runs over the same SSH pool the daemon
* already authenticates with `CONTAINERS_SSH_KEY`; ON by default (thresholds in
* `containers-env.ts`).
*/
async function processNodeDiskCleanupCycle(): Promise<NodeDiskCleanupSummary> {
const { processNodeDiskCleanup } = await loadDeps();
const report = await processNodeDiskCleanup();
return {
nodesScanned: report.nodesScanned,
nodesSkipped: report.nodesSkipped,
pruned: report.pruned,
pruneFailed: report.pruneFailed,
};
}
/**
* Verify a bounded sample of stored agent backups is actually RESTORABLE with
* the current KMS keys (#15603 B5): decrypt the newest backup per agent,
* replay incremental chains, validate content/manifest hashes, stamp the
* outcome, and page on failure. Exists because staging ran a memory KMS for
* weeks and every backup rotted undecryptable until restores failed (#15310) —
* the startup preflight refuses that config going forward, but only this cycle
* proves the backups already on disk still decrypt. Read-only against backup
* storage; batch/interval/escalation are env-tunable in the verifier service
* (`BACKUP_VERIFICATION_*`, on by default). Exported for the daemon-phase
* wiring test.
*/
export async function processBackupVerificationCycle(): Promise<
Awaited<ReturnType<WorkerRunBackupVerificationCycle>>
> {
const { runBackupVerificationCycle } = await loadDeps();
return runBackupVerificationCycle();
}
/**
* Reconcile the `allocated_count` column on each `docker_nodes` row against
* the real number of provisioned sandboxes referencing the node. Previously
* fired by `agent-hot-pool` cron forwarded to the mystery control-plane
* host; folded here so the orchestrator owns the truth.
*/
async function processSyncAllocatedCountsCycle(): Promise<number> {
const { dockerNodeManager } = await loadDeps();
const changes = await dockerNodeManager.syncAllocatedCounts();
return changes.size;
}
/**
* Pre-pull the current agent image on every healthy node with spare
* capacity. Keeps the warm pool / cold-start path fast. Gated by
* `ELIZA_AGENT_HOT_POOL_PREPULL` (default on).
*/
async function processPrePullImagesCycle(): Promise<PrePullImagesSummary | null> {
if (process.env.ELIZA_AGENT_HOT_POOL_PREPULL === "false") return null;
const { dockerNodeManager, containersEnv } = await loadDeps();
const image = containersEnv.defaultAgentImage();
const result =
await dockerNodeManager.prePullAgentImageOnAvailableNodes(image);
const failed = result.filter((n) => n.status === "failed").length;
return { attempted: result.length, failed };
}
/**
* Evaluate capacity and scale Hetzner-cloud autoscaled nodes up or down.
* Was forwarded to control-plane via `node-autoscale` cron; folded here.
*
* Requires `HCLOUD_TOKEN` + `CONTAINERS_AUTOSCALE_PUBLIC_SSH_KEY` on the
* daemon host for scale-up to succeed. Without those, the cycle still
* runs (decision + drain) but reports `scale_up_skipped`.
*/
async function processNodeAutoscaleCycle(): Promise<NodeAutoscaleSummary> {
const { getNodeAutoscaler } = await loadDeps();
const autoscaler = getNodeAutoscaler();
const decision = await autoscaler.evaluateCapacity();
if (!decision.shouldScaleUp && decision.shouldScaleDownNodeIds.length === 0) {
return { action: "noop" };
}
if (decision.shouldScaleUp) {
const publicKey = process.env.CONTAINERS_AUTOSCALE_PUBLIC_SSH_KEY?.trim();
if (!publicKey) {
return {
action: "scale_up_skipped",
detail: "CONTAINERS_AUTOSCALE_PUBLIC_SSH_KEY not set on daemon host",
};
}
try {
const provisioned = await autoscaler.provisionNode(
{},
{
controlPlanePublicKey: publicKey,
registrationUrl: process.env.CONTAINERS_BOOTSTRAP_CALLBACK_URL,
registrationSecret: process.env.CONTAINERS_BOOTSTRAP_SECRET,
},
);
return {
action: "scale_up",
detail: `${provisioned.nodeId} (${provisioned.hostname})`,
};
} catch (error) {
return {
action: "scale_up_failed",
detail: formatErrorWithCause(error),
};
}
}
// Scale down path. Drain only the first candidate per cycle to avoid
// draining the whole pool on a single cron tick if multiple nodes show
// up as idle simultaneously.
const target = decision.shouldScaleDownNodeIds[0];
if (!target) {
return { action: "noop" };
}
try {
await autoscaler.drainNode(target, { deprovision: true });
return { action: "scale_down", detail: target };
} catch (error) {
return {
action: "drain_failed",
detail: `${target}: ${formatErrorWithCause(error)}`,
};
}
}
interface FleetUpgradeSummary {
action: "noop" | "skip_no_digest" | "skip_capacity" | "enqueued";
configuredImage?: string;
targetDigest?: string | null;
candidates?: number;
enqueued?: number;
inFlight?: number;
detail?: string;
}
const MAX_INFLIGHT_UPGRADES = 3;
/**
* Detect when the registry-side digest of the configured agent tag has moved
* (e.g. a new `:develop` image was pushed) and enqueue blue/green
* `agent_upgrade` jobs for every running agent still on the old digest.
*
* Rate-limited to at most `MAX_INFLIGHT_UPGRADES` upgrade jobs in flight at
* any time so the fleet is never fully disrupted at once. The actual swap is
* zero-downtime (a new container is provisioned on a different node, traffic
* is atomically swapped, then the old container gets a 30s SIGTERM drain
* before removal), so the rate limit is about resource pressure on
* docker_nodes (each in-flight swap holds capacity on two nodes briefly),
* not user-facing impact.
*
* Returns "skip_no_digest" when the registry probe can't resolve a digest —
* e.g. the operator pinned a non-ghcr image like `eliza-agent:prod-good`, or
* the registry is unreachable. The reconciler simply waits for the next tick.
*/
async function processFleetUpgradeCycle(): Promise<FleetUpgradeSummary> {
const {
containersEnv,
resolveImageDigest,
agentSandboxesRepository,
jobsRepository,
provisioningJobService,
} = await loadDeps();
const configuredImage = containersEnv.defaultAgentImage();
const targetDigest = await resolveImageDigest(configuredImage);
if (!targetDigest) {
return {
action: "skip_no_digest",
configuredImage,
targetDigest,
detail: "registry probe returned null",
};
}
const inFlight = await jobsRepository.countInFlightByType("agent_upgrade");
const slack = MAX_INFLIGHT_UPGRADES - inFlight;
if (slack <= 0) {
return {
action: "skip_capacity",
configuredImage,
targetDigest,
inFlight,
};
}
const candidates =
await agentSandboxesRepository.listRunningWithDigestOtherThan(
targetDigest,
configuredImage,
slack,
);
if (candidates.length === 0) {
return { action: "noop", configuredImage, targetDigest, inFlight };
}
const { logger } = await loadDeps();
let enqueued = 0;
for (const c of candidates) {
try {
const result = await provisioningJobService.enqueueAgentUpgradeOnce({
agentId: c.id,
organizationId: c.organization_id,
userId: c.user_id,
fromDigest: c.image_digest,
toDigest: targetDigest,
dockerImage: configuredImage,
});
if (result.created) enqueued += 1;
} catch (err) {
logger.warn("[provisioning-worker] fleet-upgrade enqueue failed", {
agentId: c.id,
error: formatErrorWithCause(err),
});
}
}
return {
action: "enqueued",
configuredImage,
targetDigest,
candidates: candidates.length,
enqueued,
inFlight,
};
}
/**
* Drain warm-pool sandboxes that have been idle past their TTL. Replaces the
* `pool-drain-idle` cron path.
*/
async function processPoolDrainIdleCycle(): Promise<PoolDrainSummary> {
const { containersEnv } = await loadDeps();
const image = containersEnv.defaultAgentImage();
const pool = await getWarmPoolManager();
const result = await pool.drainIdle(image);
return { drained: result.drained.length };
}
/**
* Refill the warm pool up to its forecast target. THIS IS THE MISSING CALLER:
* `WarmPoolManager.replenish()` had no live invocation anywhere in the tree —
* the only historical caller was the deprecated (undeployed) container-control-
* plane service, and the `/api/v1/cron/pool-replenish` CF stub only *claimed*
* the daemon owned it via `cronSupersededByDaemon` while the daemon wired
* `drainIdle` ONLY. Net effect: the pool got claimed + idle-drained but NEVER
* refilled, so after depletion every create silently fell through to the
* 30-120s cold path (Nubs' "warm pool -> provision taking long" report).
*
* `replenish` is itself gated by `WARM_POOL_ENABLED` (returns a no-op decision
* when off) and self-bounds each burst to `WarmPoolPolicy.replenishBurstLimit`
* (default 3) capped by headroom to `maxPoolSize`, so it cannot stampede. It
* runs AFTER the node-health / autoscale phases below so it sees fresh node
* capacity when it decides how many to create.
*/
export async function processPoolReplenishCycle(): Promise<PoolReplenishSummary> {
const { containersEnv } = await loadDeps();
const image = containersEnv.defaultAgentImage();
const pool = await getWarmPoolManager();
const result = await pool.replenish(image);
return {
created: result.created.length,
failed: result.failed.length,
reason: result.decision.reason,
};
}
/**
* FIX 3: sweep HEALTHY nodes for `agent-<id>` containers with no live DB row
* (or a terminal-state row) and force-remove them. The reconciler itself only
* touches nodes whose status is `healthy`, which the preceding node-health
* check just refreshed, so a transient SSH blip can never reap live containers.
*/
async function processOrphanReconcilerCycle(): Promise<OrphanReconcileResult> {
const { reconcileOrphanContainersOnNodes } = await loadDeps();
return reconcileOrphanContainersOnNodes();
}
/**
* Apps (Product 2) sibling of the agent orphan reconciler: sweep HEALTHY nodes
* for `app-<slug>` containers with no live `containers` row (or a terminal-state
* row) and force-remove them. App-container teardown only runs on an explicit
* app delete, so a mid-deploy crash or partial failure leaks an app container on
* a node forever; this closes that gap. Same safety model as the agent sweep:
* it only touches `healthy` nodes (refreshed by the preceding node-health check),
* skips a node whose listing failed, and reaps by the immutable container id.
*/
async function processAppOrphanReconcilerCycle(): Promise<AppOrphanReconcileResult> {
const { reconcileOrphanAppContainersOnNodes } = await loadDeps();
return reconcileOrphanAppContainersOnNodes();
}
let running = true;
let lastInfraMaintenanceAt = 0;
/**
* Resolver for the in-flight poll sleep, so `requestShutdown` can cut it short
* and a SIGTERM that lands mid-interval drains immediately instead of waiting
* out up to `pollIntervalMs` first. Null while no sleep is pending.
*/
let wakePollSleep: (() => void) | null = null;
/**
* Heartbeat cadence — independent of the work cycle. The liveness key lives
* 60s in Redis (PROVISIONING_WORKER_HEARTBEAT_TTL_S); publishing every 15s
* leaves room for 3 missed publishes before the gate trips. Decoupling this
* from `pollCycle` is THE fix: a slow agent-delete or node health-check can no
* longer starve the heartbeat and make cloud-api reject every provision.
*/
const HEARTBEAT_INTERVAL_MS = 15_000;
/**
* If the work cycle stops completing for this long, the worker is wedged
* (e.g. Redis healthy but Neon/SSH hung). We deliberately STOP publishing the
* heartbeat so cloud-api fails CLOSED — `checkProvisioningWorkerHealth` sees
* the stale key and 503s new provisions instead of routing them to a worker
* that can't make progress — and the loud error log flags it for a human.
*
* On top of failing-closed, the worker self-restarts: once the watchdog stays
* tripped for `watchdogConsecutiveTicks` consecutive heartbeat ticks, it logs
* loudly and `process.exit(1)`s so systemd `Restart=always` relaunches it. The
* K-tick gate keeps a slow-but-healthy cycle from restart-looping. Gated by
* `PROVISIONING_WORKER_SELF_RESTART` (default on) so it can be disabled.
*
* Timing (FIX G): the watchdog trips once a cycle has not completed for
* WATCHDOG_MAX_CYCLE_MS. The self-restart then fires on the K-th consecutive
* heartbeat tick AFTER it trips, so the wall-clock from the last good cycle to
* exit(1) is
* ≈ WATCHDOG_MAX_CYCLE_MS + (K-1) * HEARTBEAT_INTERVAL_MS
* For the defaults (300s + (2-1)*15s) that is ≈ 315s — NOT ~600s / "~2x the
* window". The first wedged tick only arms the counter (no restart); the K-th
* tick fires it.
*/
const WATCHDOG_MAX_CYCLE_MS = 5 * 60_000;
/**
* Per-phase wall-clock budget. Each cycle phase (provisioning, node health,
* autoscale, …) is wrapped in `withTimeout` so a single hung phase — a Neon
* stall or an unresponsive node's SSH probe — frees fast instead of running
* unbounded. `withTimeout` only frees the awaiter — every leaf already has its
* own hard SSH/HTTP/Redis timeout, so a freed phase leaves no truly-unbounded
* I/O. This is the fast per-phase wedge signal; the cycle-wide budget below is
* what actually keeps the watchdog invariant from being violated.
*/
const PHASE_TIMEOUT_MS = 60_000;
/**
* Whole-WORK-cycle wall-clock budget (FIX E). The 4 WORK phases run on the
* watchdog's critical path, so SUM(their budgets) + the poll interval MUST stay
* below WATCHDOG_MAX_CYCLE_MS — otherwise a slow-but-progressing cycle could
* exceed the window and the worker would self-restart (the exact false positive
* the design claims to prevent: 4 × 90s = 360s > 300s under the old per-phase
* 90s budgets). Instead of summing N per-phase timeouts, the whole WORK group
* is bounded ONCE by this budget. Picked comfortably below the watchdog window:
* WORK_CYCLE_TIMEOUT_MS + DEFAULT_POLL_INTERVAL_MS = 240s + 30s = 270s < 300s.
* Adding a 5th WORK phase therefore cannot re-break the invariant — the group
* budget is fixed. `assertWatchdogInvariant()` pins this at module load and the
* unit test pins it too (FIX F).
*/
const WORK_CYCLE_TIMEOUT_MS = 4 * 60_000;
/**
* The watchdog invariant, surfaced for the unit test (FIX F) so adding a phase
* or bumping a timeout can't silently violate it. The work cycle is bounded as
* a GROUP by WORK_CYCLE_TIMEOUT_MS, so that single budget (plus the poll gap)
* is what must fit inside the watchdog window — not the per-phase budgets.
*/
export const WORKER_TIMING = {
watchdogMaxCycleMs: WATCHDOG_MAX_CYCLE_MS,
workCycleTimeoutMs: WORK_CYCLE_TIMEOUT_MS,
phaseTimeoutMs: PHASE_TIMEOUT_MS,
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
defaultPollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
} as const;
/**
* Guard the watchdog invariant at module load: the WORK cycle's wall-clock
* budget plus the time the loop sleeps between cycles must finish well before
* the watchdog declares the worker wedged. A future edit that bumps
* WORK_CYCLE_TIMEOUT_MS or lowers WATCHDOG_MAX_CYCLE_MS into violation throws
* loudly at startup instead of shipping a worker that self-restarts mid-cycle.
*/
function assertWatchdogInvariant(): void {
if (
WORK_CYCLE_TIMEOUT_MS + DEFAULT_POLL_INTERVAL_MS >=
WATCHDOG_MAX_CYCLE_MS
) {
throw new Error(
"[provisioning-worker] watchdog invariant violated: " +
`WORK_CYCLE_TIMEOUT_MS (${WORK_CYCLE_TIMEOUT_MS}) + DEFAULT_POLL_INTERVAL_MS ` +
`(${DEFAULT_POLL_INTERVAL_MS}) must be < WATCHDOG_MAX_CYCLE_MS (${WATCHDOG_MAX_CYCLE_MS}). ` +
"A slow-but-progressing work cycle would otherwise trip the watchdog and self-restart.",
);
}
}
assertWatchdogInvariant();
/**
* Liveness gate. The heartbeat interval publishes ONLY when this is true.
* Set true immediately after `assertProvisioningWorkerPreflight()` succeeds,
* false on any throw. Default false so a KMS-dead worker never advertises
* healthy before the first preflight has run.
*/
let preflightOk = false;
/** Wall-clock of the last `pollCycle` that returned. Drives the watchdog. */
let lastCycleCompletedAt = Date.now();
/** In-flight guard so a hung Redis write can't pile up unresolved publishes. */
let heartbeatPublishInFlight = false;
/**
* Consecutive heartbeat ticks the watchdog has been tripped. Reset to 0 on any
* tick where the work cycle is progressing. Drives the self-restart decision.
*/
let consecutiveWatchdogTicks = 0;
/**
* Pure decision for FIX 1's self-restart: given how many consecutive ticks the
* watchdog has been tripped, whether the feature is enabled, and the K
* threshold, decide whether the worker should exit(1) now. Exported for unit
* testing the K-consecutive-tick trigger without spawning a process.
*/
export function evaluateSelfRestart(deps: {
watchdogTripped: boolean;
consecutiveTicks: number;
selfRestartEnabled: boolean;
threshold: number;
}): { nextConsecutiveTicks: number; shouldRestart: boolean } {
if (!deps.watchdogTripped) {
return { nextConsecutiveTicks: 0, shouldRestart: false };
}
const nextConsecutiveTicks = deps.consecutiveTicks + 1;
const shouldRestart =
deps.selfRestartEnabled && nextConsecutiveTicks >= deps.threshold;
return { nextConsecutiveTicks, shouldRestart };
}
/**
* Poll-loop sleep that `requestShutdown` wakes early on SIGTERM/SIGINT.
* Exported for unit testing the wake path.
*/
export async function pollSleep(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
wakePollSleep = null;
resolve();
}, ms);
wakePollSleep = () => {
clearTimeout(timer);
wakePollSleep = null;
resolve();
};
});
}
async function publishHeartbeat(logger: WorkerLogger): Promise<void> {
try {
const { publishProvisioningWorkerHeartbeat } = await import(
"@elizaos/cloud-shared/lib/services/provisioning-worker-health"
);
await publishProvisioningWorkerHeartbeat();
} catch (error) {
logger.warn("[provisioning-worker] heartbeat publish failed", {
error: formatErrorWithCause(error),
});
}
}
/**
* Decide whether the heartbeat may be published this tick, and publish it.
* Gated on three independent conditions, all of which must hold:
* 1. `preflightOk` — KMS is usable (a KMS-dead worker must never advertise
* healthy, mirroring the old in-cycle preflight gate).
* 2. the watchdog has not tripped — the work cycle is still progressing.
* 3. no publish is already in flight — a slow/hung Redis write must not pile
* up unresolved promises.
* Exported for unit testing the gate logic.
*/
export async function maybePublishHeartbeat(
logger: WorkerLogger,
deps: {
preflightOk: boolean;
lastCycleCompletedAt: number;
now?: number;
publish?: (logger: WorkerLogger) => Promise<void>;
},
): Promise<{ published: boolean; watchdogTripped: boolean }> {
const now = deps.now ?? Date.now();
const publish = deps.publish ?? publishHeartbeat;
if (now - deps.lastCycleCompletedAt > WATCHDOG_MAX_CYCLE_MS) {
logger.error(
"[provisioning-worker] WATCHDOG: work cycle has not completed in over " +
`${Math.round(WATCHDOG_MAX_CYCLE_MS / 1000)}s — worker appears wedged. ` +
"Withholding heartbeat so cloud-api fails closed (stops routing provisions here); restart this worker.",
{
lastCycleCompletedAt: new Date(deps.lastCycleCompletedAt).toISOString(),
},
);
return { published: false, watchdogTripped: true };
}
// KMS-dead worker must never advertise healthy.
if (!deps.preflightOk) {
return { published: false, watchdogTripped: false };
}
await publish(logger);
return { published: true, watchdogTripped: false };
}
/**
* Loudly log and exit so systemd `Restart=always` relaunches the worker. Split
* out so the side-effecting `process.exit` is in one place and the rest of the
* tick logic stays pure/testable. Allows injecting `exit` in tests.
*/
function triggerSelfRestart(
logger: WorkerLogger,
consecutiveTicks: number,
exit: (code: number) => never = process.exit,
): never {
logger.error(
"[provisioning-worker] WATCHDOG self-restart: work cycle wedged for " +
`${consecutiveTicks} consecutive heartbeat ticks (>=${Math.round(
WATCHDOG_MAX_CYCLE_MS / 1000,
)}s stale). Exiting(1) so systemd relaunches a fresh worker.`,
{
event: "worker.self_restart",
consecutiveWatchdogTicks: consecutiveTicks,
lastCycleCompletedAt: new Date(lastCycleCompletedAt).toISOString(),
},
);
return exit(1);
}
/**
* Start the independent heartbeat timer. Decoupled from `pollCycle` so a slow
* work item can never starve the liveness key. The in-flight guard skips a
* tick rather than queueing a second publish behind a hung Redis write.
*
* Each tick also advances the self-restart watchdog: when the heartbeat is
* withheld because the work cycle is wedged, the consecutive-tick counter
* climbs; once it crosses `config.watchdogConsecutiveTicks`, the worker
* self-restarts (FIX 1).
*/
function startHeartbeatInterval(
logger: WorkerLogger,
config: ProvisioningWorkerConfig,
): NodeJS.Timeout {
const tick = () => {
if (heartbeatPublishInFlight) return;
heartbeatPublishInFlight = true;
void maybePublishHeartbeat(logger, {
preflightOk,
lastCycleCompletedAt,
})
.then(({ watchdogTripped }) => {
const { nextConsecutiveTicks, shouldRestart } = evaluateSelfRestart({
watchdogTripped,
consecutiveTicks: consecutiveWatchdogTicks,
selfRestartEnabled: config.selfRestartEnabled,
threshold: config.watchdogConsecutiveTicks,
});
consecutiveWatchdogTicks = nextConsecutiveTicks;
// Don't self-restart (exit 1) while draining: a SIGTERM that flips
// `running` should exit 0, not be turned into a wedge-restart.
if (shouldRestart && running) {
triggerSelfRestart(logger, nextConsecutiveTicks);
}
})
.finally(() => {
heartbeatPublishInFlight = false;
});
};
const timer = setInterval(tick, HEARTBEAT_INTERVAL_MS);
timer.unref();
return timer;
}
/**
* Run one cycle phase under a hard wall-clock budget (FIX 2). The phase is
* wrapped in `withTimeout` (so a hung phase frees the cycle) and its result is
* handed to `onResult` for logging; any throw — including the timeout — is
* caught and logged so one phase failing never aborts the rest of the cycle.
*/
async function runBoundedPhase<T>(
logger: WorkerLogger,
label: string,
phase: () => Promise<T>,
onResult: (result: T) => void,
): Promise<void> {
const { withTimeout } = await loadDeps();
try {
const result = await withTimeout(
phase(),
PHASE_TIMEOUT_MS,
`[provisioning-worker] ${label}`,
);
onResult(result);
} catch (error) {
logger.error(`[provisioning-worker] ${label} failed`, {
error: formatErrorWithCause(error),
});
}
}
/**
* Run the 4 WORK phases (claim/complete jobs, agent heartbeats, recovery, fleet
* upgrade) as a single group bounded by WORK_CYCLE_TIMEOUT_MS (FIX E). Bounding
* the group once — rather than summing N per-phase 90s timeouts (4 × 90s = 360s
* > the 300s watchdog window) — keeps the critical-path budget below
* WATCHDOG_MAX_CYCLE_MS, so a slow-but-progressing cycle can't self-restart. The
* group timeout is caught and logged so it frees the cycle without aborting the
* rest of pollCycle; the per-phase timeouts inside still give a fast,
* per-phase wedge signal.
*/
async function runWorkCycle(
logger: WorkerLogger,
config: ProvisioningWorkerConfig,
): Promise<void> {
const { withTimeout } = await loadDeps();
const work = (async () => {
await runBoundedPhase(
logger,
"cycle",
() => processProvisioningWorkerCycle(config.batchSize, config.jobTypes),
(result) => {
if (result.claimed > 0 || result.failed > 0) {
logger.info(
"[provisioning-worker] cycle complete",
resultContext(result),
);
}
},
);
await runBoundedPhase(
logger,
"heartbeat cycle",
() => processHeartbeatCycle(),
(heartbeats) => {
if (heartbeats.total > 0) {
logger.info("[provisioning-worker] heartbeat cycle complete", {
total: heartbeats.total,
succeeded: heartbeats.succeeded,
failed: heartbeats.failed,
});
}
},
);
await runBoundedPhase(
logger,
"recovery cycle",
() => processRecoveryCycle(),
(recovery) => {
if (recovery.total > 0) {
logger.info("[provisioning-worker] recovery cycle complete", {
total: recovery.total,
recovered: recovery.recovered,
reprovisioned: recovery.reprovisioned,
failed: recovery.failed,
});
}
},
);
await runBoundedPhase(
logger,
"stuck-provisioning reconcile",
() => processStuckProvisioningReconcileCycle(),
(reconcile) => {
if (reconcile.total > 0) {
logger.info(
"[provisioning-worker] stuck-provisioning reconcile complete",
{
total: reconcile.total,
recovered: reconcile.recovered,
unresolved: reconcile.unresolved,
failed: reconcile.failed,
},
);
}
},
);
await runBoundedPhase(
logger,
"fleet upgrade cycle",
() => processFleetUpgradeCycle(),
(decision) => {
if (decision.action !== "noop") {
logger.info("[provisioning-worker] fleet upgrade cycle complete", {
action: decision.action,
configuredImage: decision.configuredImage,
targetDigest: decision.targetDigest,
candidates: decision.candidates,
enqueued: decision.enqueued,
inFlight: decision.inFlight,
detail: decision.detail,
});
}
},
);
})();
try {
await withTimeout(
work,
WORK_CYCLE_TIMEOUT_MS,
"[provisioning-worker] work cycle",
);
} catch (error) {
// A group-level timeout frees the cycle (every leaf is independently
// bounded) so the watchdog clock advances and infra maintenance still runs.
logger.error("[provisioning-worker] work cycle exceeded its budget", {
error: formatErrorWithCause(error),
workCycleTimeoutMs: WORK_CYCLE_TIMEOUT_MS,
});
}
}
async function pollCycle(
logger: WorkerLogger,
config: ProvisioningWorkerConfig,
): Promise<void> {
// Re-validate the preflight at the top of every cycle (cheap — a local KMS
// getOrCreateKey). The heartbeat interval owns liveness now, but it only
// publishes while `preflightOk` is true, so this is what keeps a KMS-dead
// worker from advertising healthy. Set true ONLY immediately after success.
try {
await assertProvisioningWorkerPreflight({ logger });
preflightOk = true;
} catch (error) {
preflightOk = false;
logger.error(
"[provisioning-worker] preflight failed; withholding heartbeat",
{
error: formatErrorWithCause(error),
},
);
return;
}
// ── WORK phases (on the watchdog's critical path) ────────────────────────
// The WHOLE group is bounded ONCE by WORK_CYCLE_TIMEOUT_MS (FIX E) so the
// critical-path budget stays below WATCHDOG_MAX_CYCLE_MS no matter how many
// phases there are — 4 × per-phase 90s used to sum to 360s and could trip the
// 300s watchdog on a slow-but-progressing cycle. Each phase keeps its own
// (now-shorter) per-phase timeout as a fast individual-wedge signal; the group
// bound is the authoritative one. A group-level timeout frees the cycle and is
// logged, never aborting the rest of pollCycle (infra maintenance still runs).
await runWorkCycle(logger, config);
// Mark progress for the watchdog HERE — after the WORK cycle, BEFORE infra
// maintenance (FIX 2). Infra maintenance (SSH + Docker probes across the
// whole fleet) is slow and off the critical liveness path: a node-health or
// orphan-reconcile sweep that drags on must not make a worker that is still
// claiming and completing jobs look wedged. As long as the WORK cycle keeps
// advancing this, the heartbeat interval keeps the worker healthy.
lastCycleCompletedAt = Date.now();
// ── Infra maintenance (off the watchdog path) ────────────────────────────
// Runs on a longer interval than the heartbeat (SSH + Docker probes per node
// are expensive). Bundles every job that used to be forwarded from CF crons
// to the now-deprecated control-plane:
// - node health check (was: /api/v1/cron/agent-hot-pool — healthCheckAll)
// - alloc reconciliation (was: agent-hot-pool — syncAllocatedCounts)
// - pre-pull warm image (was: agent-hot-pool — prePullAgentImageOnAvailableNodes)
// - node autoscale (was: /api/v1/cron/node-autoscale)
// - warm pool drain (was: /api/v1/cron/pool-drain-idle)
// - warm pool replenish (was: /api/v1/cron/pool-replenish — the CF stub
// claimed the daemon owned it but no phase actually called replenish;
// now wired for real below, after node-health so it sees fresh capacity)
// - orphan reconcile (FIX 3, gated by ORPHAN_RECONCILER_ENABLED)
// Folding them together avoids 3 parallel writers fighting over the same
// docker_nodes rows and means there's exactly one host that owns the
// truth: the orchestrator (this daemon). `lastInfraMaintenanceAt`
// initializes to 0 so the first poll always runs — we want a fresh
// node-status snapshot at worker startup.
const now = Date.now();
if (now - lastInfraMaintenanceAt >= config.nodeHealthIntervalMs) {
lastInfraMaintenanceAt = now;
await runInfraMaintenanceCycle(logger, config);
}
}
async function runInfraMaintenanceCycle(
logger: WorkerLogger,
config: ProvisioningWorkerConfig,
): Promise<void> {
// Every phase is bounded by PHASE_TIMEOUT_MS via runBoundedPhase so an
// unresponsive node's SSH probe can never stall the whole sweep.
// #15160 guard, periodic half: re-assert DB liveness on every infra
// maintenance sweep (default every 5min ≈ every 10th poll cycle) so a
// worker polling an abandoned database keeps screaming instead of logging
// once at boot and going silent. First so a dragging SSH sweep can't
// starve it. Runs BEFORE the health check touches docker_nodes, so it is
// a pure read.
await runBoundedPhase(
logger,
"db liveness check cycle",
() => processDbLivenessCheckCycle(config),
(assessment) => logJobsTableLiveness(logger, assessment),
);
await runBoundedPhase(
logger,
"node health check cycle",
() => processNodeHealthCheckCycle(),
(summary) => {
logger.info("[provisioning-worker] node health check cycle complete", {
total: summary.total,
healthy: summary.healthy,
unhealthy: summary.unhealthy,
});
},
);
// Disk retention runs right after the node-health check so it sees fresh node
// status (only `healthy` nodes are pruned) and BEFORE the image pre-pull, so a
// node we just reclaimed has room for the next warm-image pull instead of
// failing it on `no space left on device`. Bounded by PHASE_TIMEOUT_MS like
// every infra phase.
await runBoundedPhase(
logger,
"node disk cleanup cycle",
() => processNodeDiskCleanupCycle(),
(summary) => {
if (summary.pruned > 0 || summary.pruneFailed > 0) {
logger.info("[provisioning-worker] node disk cleanup cycle complete", {
event: "node_disk_cleanup.pruned",
nodesScanned: summary.nodesScanned,
nodesSkipped: summary.nodesSkipped,
pruned: summary.pruned,
pruneFailed: summary.pruneFailed,
});
}
},
);
// Backup restorability verification (#15603 B5). Off the watchdog critical
// path like every infra phase; self-gated by BACKUP_VERIFICATION_ENABLED and
// bounded to a small batch per sweep, so the KMS decrypts + object-storage
// reads it performs cannot starve node maintenance. Failures alert through
// the same ops channels as the daemon heartbeat monitor.
await runBoundedPhase(
logger,
"backup verification cycle",
() => processBackupVerificationCycle(),
(summary) => {
if (summary.sampled > 0 || summary.errored > 0) {
logger.info(
"[provisioning-worker] backup verification cycle complete",
{
event: "backup_verification.cycle",
sampled: summary.sampled,
verified: summary.verified,
failed: summary.failed,
errored: summary.errored,
oversizeSkipped: summary.oversizeSkipped,
budgetDeferred: summary.budgetDeferred,
escalated: summary.escalated,
},
);
}
},
);
await runBoundedPhase(
logger,
"alloc reconcile cycle",
() => processSyncAllocatedCountsCycle(),
(changes) => {
if (changes > 0) {
logger.info("[provisioning-worker] alloc reconcile cycle complete", {
changed: changes,
});
}
},
);
await runBoundedPhase(
logger,
"pre-pull images cycle",
() => processPrePullImagesCycle(),
(summary) => {
if (summary) {
logger.info("[provisioning-worker] pre-pull images cycle complete", {
attempted: summary.attempted,
failed: summary.failed,
});
}
},
);
await runBoundedPhase(
logger,
"node autoscale cycle",
() => processNodeAutoscaleCycle(),
(decision) => {
if (decision.action !== "noop") {
logger.info("[provisioning-worker] node autoscale cycle complete", {
action: decision.action,
detail: decision.detail,
});
}
},
);
await runBoundedPhase(
logger,
"warm pool drain cycle",
() => processPoolDrainIdleCycle(),
(result) => {
if (result.drained > 0) {
logger.info("[provisioning-worker] warm pool drain cycle complete", {
drained: result.drained,
});
}
},
);
// Refill the warm pool up to its forecast target. Runs AFTER node-health,
// autoscale, and idle-drain so it decides how many containers to create
// against fresh node capacity and post-drain pool depth. Error-isolated by
// runBoundedPhase like every other phase: a replenish failure logs and the
// rest of the cycle (and the next poll) continues. Self-gated by
// WARM_POOL_ENABLED (no-op decision when off) and bounded per burst by the
// policy's replenishBurstLimit, so it cannot stampede.
await runBoundedPhase(
logger,
"warm pool replenish cycle",
() => processPoolReplenishCycle(),
(result) => {
if (result.created > 0 || result.failed > 0) {
logger.info(
"[provisioning-worker] warm pool replenish cycle complete",
{
event: "warm_pool.replenished",
created: result.created,
failed: result.failed,
reason: result.reason,
},
);
}
},
);
// FIX 3: orphan-container reconciliation. Runs LAST so it sees the fresh
// node-status from the health check above — the reconciler only touches
// HEALTHY nodes, so a node that just failed its probe is excluded and a
// transient SSH blip never reaps live containers. Gated OFF by default.
if (config.orphanReconcilerEnabled) {
await runBoundedPhase(
logger,
"orphan reconciler cycle",
() => processOrphanReconcilerCycle(),
(result) => {
logger.info("[provisioning-worker] orphan reconciler cycle complete", {
event: "orphan_reconciler.reaped",
nodesScanned: result.nodesScanned,
nodesSkipped: result.nodesSkipped,
reaped: result.reaped,
reapFailed: result.reapFailed,
});
},
);
// Apps (Product 2) sibling sweep — same `healthy`-only, fresh-node-status,
// reap-by-id model. Runs right after the agent sweep so it shares the same
// freshly-refreshed node health and the same `docker rm -f` gating.
await runBoundedPhase(
logger,
"app orphan reconciler cycle",
() => processAppOrphanReconcilerCycle(),
(result) => {
logger.info(
"[provisioning-worker] app orphan reconciler cycle complete",
{
event: "app_orphan_reconciler.reaped",
nodesScanned: result.nodesScanned,
nodesSkipped: result.nodesSkipped,
reaped: result.reaped,
reapFailed: result.reapFailed,
},
);
},
);
}
}
/**
* Apps (Product 2): arm the node deploy backend so the daemon runs APP_DEPLOY +
* CONTAINER_* jobs (provision an isolated per-tenant DB -> run an isolated
* container with that DSN). Gated OFF by default — only when
* `APPS_DEPLOY_ENABLED=1`. Additive + safe: when off, the cloud-api deploy
* trigger is also gated off, so no APP_DEPLOY/CONTAINER_* jobs are ever enqueued,
* the executor seam is never queried, and Product-1 (agents) is untouched.
*
* Defaults to the PREBUILT-image path proven on staging (no `buildExec`): images
* resolve from `app.metadata.imageTag` / `APP_DEFAULT_IMAGE`. The cluster admin
* DSN is env-sourced via `APPS_TENANT_ADMIN_DSN` (no `SECRETS_MASTER_KEY`).
*/
async function armAppsDeployBackendIfEnabled(
logger: WorkerLogger,
): Promise<void> {
if (process.env.APPS_DEPLOY_ENABLED !== "1") return;
const { configureAppsDeployBackend } = await import(
"@elizaos/cloud-shared/lib/services/apps-deploy-backend"
);
const port = process.env.APPS_DEPLOY_PORT
? Number(process.env.APPS_DEPLOY_PORT)
: undefined;
// When APPS_IMAGE_REGISTRY is set, the backend arms BUILD-FROM-REPO (builds the
// user's repo on the app node via buildx and pushes to this registry — the
// Vercel-like path). Unset → prebuilt images (imageTag/APP_DEFAULT_IMAGE).
const registry = process.env.APPS_IMAGE_REGISTRY;
configureAppsDeployBackend({ port, registry });
logger.info("[provisioning-worker] apps deploy backend armed", {
tenantDbAdminDsn: process.env.APPS_TENANT_ADMIN_DSN
? "env-sourced"
: "encrypted",
images: registry
? "build-from-repo"
: "prebuilt (imageTag/APP_DEFAULT_IMAGE)",
registry: registry ?? null,
port: port ?? 3000,
});
}
async function main(): Promise<void> {
loadLocalEnv(import.meta.url);
const config = readWorkerConfig();
const { logger, assertSSHKeyAvailable } = await loadDeps();
logger.info("[provisioning-worker] starting", {
pollIntervalMs: config.pollIntervalMs,
batchSize: config.batchSize,
runOnce: config.runOnce,
nodeHealthIntervalMs: config.nodeHealthIntervalMs,
// Surface the claim scope: empty PROVISIONING_JOB_LANES → all 18 types.
// When a dedicated apps daemon ships, pin this one to `agent`.
jobLanes: process.env.PROVISIONING_JOB_LANES || "(all)",
jobTypeCount: config.jobTypes.length,
selfRestartEnabled: config.selfRestartEnabled,
watchdogConsecutiveTicks: config.watchdogConsecutiveTicks,
orphanReconcilerEnabled: config.orphanReconcilerEnabled,
dbLivenessMaxAgeHours: config.dbLivenessMaxAgeHours,
});
await assertProvisioningWorkerPreflight({ logger });
// Fail-fast on a missing SSH key BEFORE the first heartbeat: key resolution is
// otherwise lazy (first node SSH, ~30s in), so a misconfigured key would let
// the worker publish a healthy heartbeat while silently failing every node
// SSH forever. Crash loudly instead; systemd `Restart=always` relaunches it
// and the misconfig is visible.
try {
assertSSHKeyAvailable();
} catch (error) {
logger.error(
`[provisioning-worker] CRITICAL: ${formatErrorWithCause(error)}`,
);
throw error;
}
preflightOk = true;
logger.info("[provisioning-worker] startup preflight passed");
// #15160 guard, startup half: right after the preflight, check that the
// jobs table this DATABASE_URL points at has been written to recently.
// The KMS/SSH preflights above prove THIS worker can run jobs; this one
// flags the failure mode where there are simply never any jobs to run
// because the API writes to a different database. Bounded + caught by
// runBoundedPhase: a slow or unreachable DB logs an error here and the
// work cycles surface it properly — startup must not crash on it.
await runBoundedPhase(
logger,
"db liveness preflight",
() => processDbLivenessCheckCycle(config),
(assessment) => logJobsTableLiveness(logger, assessment),
);
// A systemd restart kills the previous singleton daemon, but any job it had
// already claimed remains `in_progress` until stale recovery sees the full
// cold-boot threshold. Recover only rows claimed before THIS process existed:
// normal in-cycle stale recovery keeps protecting legitimate slow cold boots,
// while deploy restarts immediately expose interrupted provisions to retry.
await runBoundedPhase(
logger,
"startup interrupted-job recovery",
() => processStartupInterruptedJobsRecoveryCycle(config),
(recovered) => {
if (recovered > 0) {
logger.info(
"[provisioning-worker] startup interrupted-job recovery complete",
{
recovered,
startedBefore: workerStartedAt.toISOString(),
},
);
}
},
);
// Apps (Product 2): arm the deploy backend when enabled (gated; no-op by default).
await armAppsDeployBackendIfEnabled(logger);
// Seed the watchdog clock so a slow first cycle can't trip it immediately.
lastCycleCompletedAt = Date.now();
if (config.runOnce) {
// Publish once so a --once invocation still reports liveness, then run a
// single cycle. No interval needed for the one-shot path.
await publishHeartbeat(logger);
await pollCycle(logger, config);
await closeOpenHandles(logger);
return;
}
// Decouple liveness from the work cycle: an independent timer publishes the
// heartbeat every HEARTBEAT_INTERVAL_MS regardless of how long a single
// cycle takes. Publish once immediately so there's no cold gap before the
// first tick.
await publishHeartbeat(logger);
const heartbeatTimer = startHeartbeatInterval(logger, config);
try {
while (running) {
await pollCycle(logger, config);
if (running) {
await pollSleep(config.pollIntervalMs);
}
}
} finally {
clearInterval(heartbeatTimer);
}
await closeOpenHandles(logger);
logger.info("[provisioning-worker] stopped");
}
function isMainModule(): boolean {
const entry = process.argv[1];
return entry ? path.resolve(entry) === fileURLToPath(import.meta.url) : false;
}
/**
* Close the long-lived handles the daemon owns before exiting: the static
* docker-ssh connection pool and the process-level pg pools. Best-effort — a
* failed close is logged and never blocks the exit (the force-exit timer in
* `requestShutdown` backstops a wedged teardown). Closers are injectable for
* unit tests, mirroring `maybePublishHeartbeat`.
*/
export async function closeOpenHandles(
logger: WorkerLogger,
close: {
sshPool?: () => Promise<void>;
dbPools?: () => Promise<void>;
} = {},
): Promise<void> {
const closers: [string, () => Promise<void>][] = [
[
"ssh pool",
close.sshPool ??
(async () => {
const { DockerSSHClient } = await import(
"@elizaos/cloud-shared/lib/services/docker-ssh"
);
await DockerSSHClient.disconnectAll();
}),
],
[
"db pools",
close.dbPools ??
(async () => {
// Despite the suffix, this is THE process-level pool closer
// (`connectionManager.closeAll()` → `pool.end()` per cached pool).
const { closeDatabaseConnectionsForTests } = await import(
"@elizaos/cloud-shared/db/client"
);
await closeDatabaseConnectionsForTests();
}),
],
];
const results = await Promise.allSettled(closers.map(([, fn]) => fn()));
results.forEach((result, i) => {
if (result.status === "rejected") {
logger.warn("[provisioning-worker] shutdown cleanup failed", {
handle: closers[i][0],
error: formatErrorWithCause(result.reason),
});
}
});
}
/**
* Bounded shutdown grace. SIGTERM flips `running` and lets the in-flight cycle
* drain, but a cycle can legitimately run for minutes (WORK_CYCLE_TIMEOUT_MS is
* 240s) while systemd escalates stop-sigterm to SIGKILL at 90s. Force-exiting
* ourselves at 60s keeps every stop/restart under systemd's window; the timer
* is unref'd so it never keeps a clean drain alive.
*/
const SHUTDOWN_FORCE_EXIT_MS = 60_000;
/**
* SIGTERM/SIGINT path: stop the loop, cut the poll sleep short, and arm the
* force-exit backstop. The clean path is `main()` resolving — drain the
* in-flight cycle, close handles, `process.exit(0)` at the entrypoint.
* `exit` is injectable for unit tests, mirroring `triggerSelfRestart`.
*/
export function requestShutdown(
signal: NodeJS.Signals,
exit: (code: number) => never = process.exit,
): void {
process.stderr.write(`[provisioning-worker] ${signal} received; draining\n`);
running = false;
wakePollSleep?.();
const timer = setTimeout(() => {
process.stderr.write(
`[provisioning-worker] drain exceeded ${SHUTDOWN_FORCE_EXIT_MS}ms after ${signal}; forcing exit\n`,
);
exit(0);
}, SHUTDOWN_FORCE_EXIT_MS);
timer.unref();
}
process.on("SIGINT", () => requestShutdown("SIGINT"));
process.on("SIGTERM", () => requestShutdown("SIGTERM"));
process.on("unhandledRejection", (reason) => {
void loadDeps().then(({ logger }) => {
logger.error("[provisioning-worker] unhandled rejection", {
error: formatErrorWithCause(reason),
});
});
});
if (isMainModule()) {
main().then(
() => {
// Exit explicitly: the dependency graph holds live handles the daemon
// can't enumerate (a fresh Redis socket per heartbeat publish, module-
// level intervals in shared libs), so the event loop never drains on its
// own — systemd was escalating every stop to SIGKILL after 90s.
process.exit(0);
},
(error) => {
process.stderr.write(
`[provisioning-worker] fatal: ${formatErrorWithCause(error)}\n`,
);
// Same open-handles reasoning as the clean path: `process.exitCode = 1`
// alone leaves a dead worker hanging instead of letting Restart=always
// relaunch it.
process.exit(1);
},
);
}