426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
392 lines
12 KiB
JavaScript
Executable File
392 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Filesystem GPU job-queue worker (#14549): the one long-running consumer in
|
|
* the compose `gpu` profile. Watches `<jobs>/pending/`, claims jobs by rename
|
|
* (atomic on one filesystem, so multiple producers and one worker never race),
|
|
* POSTs each job's OpenAI-shaped request to the resident gpu-vision service,
|
|
* and writes a result record beside the consumed job. All decision logic is
|
|
* pure in queue-lib.mjs; this file is fs + fetch + clock.
|
|
*
|
|
* Degradation is explicit: while the service is unreachable, jobs bounce back
|
|
* to pending and are retried; once the outage outlasts --drain-after-ms the
|
|
* worker drains — every pending job becomes a `skipped` result record naming
|
|
* the outage — so a run on a GPU-less/half-up machine finishes with honest
|
|
* skip records instead of hanging or fabricating analyses.
|
|
*
|
|
* Usage:
|
|
* node docker/certification/queue-worker.mjs --jobs <dir>
|
|
* --service ocr=http://gpu-vision:8090 --service vlm=http://gpu-vision:8091
|
|
* [--poll-ms N] [--drain-after-ms N] [--request-timeout-ms N] [--once]
|
|
*
|
|
* Producers enqueue via the exported `enqueueJob(jobsRoot, job, limits)`.
|
|
*/
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
claimOrder,
|
|
createWorkerState,
|
|
DEFAULT_LIMITS,
|
|
decideEnqueue,
|
|
makeJobId,
|
|
onServiceOk,
|
|
onServiceUnreachable,
|
|
parseJob,
|
|
QUEUE_DIRS,
|
|
QueueJobInvalidError,
|
|
resolveImagePlaceholders,
|
|
resultRecord,
|
|
shouldSkipJob,
|
|
} from "./queue-lib.mjs";
|
|
|
|
const MIME_BY_EXT = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
};
|
|
|
|
export class QueueBackpressureError extends Error {
|
|
constructor(reason) {
|
|
super(`[queue] enqueue refused — ${reason}`);
|
|
this.name = "QueueBackpressureError";
|
|
}
|
|
}
|
|
|
|
export async function ensureQueueDirs(jobsRoot) {
|
|
for (const dir of QUEUE_DIRS) {
|
|
await fs.mkdir(path.join(jobsRoot, dir), { recursive: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Producer-side enqueue with max-pending backpressure. Writes the job file
|
|
* atomically (tmp + rename) so the worker can never claim a half-written job.
|
|
* Throws QueueBackpressureError instead of dropping work.
|
|
*/
|
|
export async function enqueueJob(jobsRoot, job, limits = DEFAULT_LIMITS) {
|
|
await ensureQueueDirs(jobsRoot);
|
|
const pendingDir = path.join(jobsRoot, "pending");
|
|
const pending = claimOrder(await fs.readdir(pendingDir));
|
|
const decision = decideEnqueue(pending.length, limits.maxPending);
|
|
if (!decision.accept) throw new QueueBackpressureError(decision.reason);
|
|
|
|
const id =
|
|
typeof job.id === "string" && job.id.length > 0
|
|
? job.id
|
|
: makeJobId(Date.now(), Math.random().toString(36).slice(2, 8));
|
|
const record = { ...job, id, enqueuedAt: new Date().toISOString() };
|
|
const tmpPath = path.join(jobsRoot, `.${id}.tmp`);
|
|
await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
await fs.rename(tmpPath, path.join(pendingDir, `${id}.json`));
|
|
return id;
|
|
}
|
|
|
|
function parseServices(argv) {
|
|
const services = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
if (argv[i] !== "--service") continue;
|
|
const spec = argv[i + 1];
|
|
const eq = spec?.indexOf("=") ?? -1;
|
|
if (eq === -1) {
|
|
throw new Error(`[queue] --service expects model=baseUrl, got: ${spec}`);
|
|
}
|
|
services[spec.slice(0, eq)] = spec.slice(eq + 1).replace(/\/$/, "");
|
|
}
|
|
if (Object.keys(services).length === 0) {
|
|
throw new Error("[queue] at least one --service model=baseUrl is required");
|
|
}
|
|
return services;
|
|
}
|
|
|
|
function flagValue(argv, name, fallback) {
|
|
const i = argv.indexOf(name);
|
|
if (i === -1) return fallback;
|
|
const value = Number(argv[i + 1]);
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
throw new Error(
|
|
`[queue] ${name} must be a positive number, got: ${argv[i + 1]}`,
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function buildRequestBody(jobsRoot, job) {
|
|
if (job.imagePath === undefined) return job.request;
|
|
const imageFile = path.join(jobsRoot, job.imagePath);
|
|
const ext = path.extname(imageFile).toLowerCase();
|
|
const mime = MIME_BY_EXT[ext];
|
|
if (!mime) {
|
|
throw new QueueJobInvalidError(
|
|
`unsupported image extension: ${ext || "(none)"}`,
|
|
);
|
|
}
|
|
const bytes = await fs.readFile(imageFile);
|
|
return resolveImagePlaceholders(
|
|
job.request,
|
|
`data:${mime};base64,${bytes.toString("base64")}`,
|
|
);
|
|
}
|
|
|
|
async function writeResult(jobsRoot, record) {
|
|
const tmpPath = path.join(jobsRoot, `.${record.id}.result.tmp`);
|
|
await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
await fs.rename(tmpPath, path.join(jobsRoot, "results", `${record.id}.json`));
|
|
}
|
|
|
|
async function finishJob(jobsRoot, name, record) {
|
|
await writeResult(jobsRoot, record);
|
|
await fs.rename(
|
|
path.join(jobsRoot, "processing", name),
|
|
path.join(jobsRoot, "done", name),
|
|
);
|
|
}
|
|
|
|
async function probeServices(services, timeoutMs) {
|
|
for (const baseUrl of Object.values(services)) {
|
|
const response = await fetch(`${baseUrl}/health`, {
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
if (!response.ok)
|
|
throw new Error(`health ${response.status} from ${baseUrl}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process one claimed job file. Returns "ok" | "failed" | "skipped" |
|
|
* "unreachable"; only "unreachable" leaves the job in pending for retry.
|
|
*/
|
|
async function processClaimed(jobsRoot, name, raw, services, state, opts) {
|
|
const idFromName = name.replace(/\.json$/, "");
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
let job;
|
|
try {
|
|
job = parseJob(raw, Object.keys(services));
|
|
} catch (err) {
|
|
if (!(err instanceof QueueJobInvalidError)) throw err;
|
|
// An unparseable job can never succeed on retry; record the defect.
|
|
await finishJob(
|
|
jobsRoot,
|
|
name,
|
|
resultRecord(
|
|
{ id: idFromName, model: "unknown" },
|
|
{ status: "failed", reason: err.reason },
|
|
nowIso(),
|
|
),
|
|
);
|
|
return "failed";
|
|
}
|
|
|
|
if (shouldSkipJob(state)) {
|
|
await finishJob(
|
|
jobsRoot,
|
|
name,
|
|
resultRecord(
|
|
job,
|
|
{
|
|
status: "skipped",
|
|
reason: `gpu service unreachable past ${opts.drainAfterMs}ms — drained`,
|
|
},
|
|
nowIso(),
|
|
),
|
|
);
|
|
return "skipped";
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await buildRequestBody(jobsRoot, job);
|
|
} catch (err) {
|
|
if (!(err instanceof QueueJobInvalidError) && err.code !== "ENOENT")
|
|
throw err;
|
|
await finishJob(
|
|
jobsRoot,
|
|
name,
|
|
resultRecord(
|
|
job,
|
|
{ status: "failed", reason: `image unreadable: ${err.message}` },
|
|
nowIso(),
|
|
),
|
|
);
|
|
return "failed";
|
|
}
|
|
|
|
const startedAt = Date.now();
|
|
let response;
|
|
try {
|
|
response = await fetch(`${services[job.model]}/v1/chat/completions`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(opts.requestTimeoutMs),
|
|
});
|
|
} catch (err) {
|
|
// error-policy:J1 the service boundary: a network failure here is the
|
|
// queue's designed retry/drain signal, observed via worker state + the
|
|
// eventual skipped records — never a fabricated analysis.
|
|
process.stderr.write(
|
|
`[queue] ${job.id}: service unreachable (${err.message}) — will retry\n`,
|
|
);
|
|
await fs.rename(
|
|
path.join(jobsRoot, "processing", name),
|
|
path.join(jobsRoot, "pending", name),
|
|
);
|
|
return "unreachable";
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
await finishJob(
|
|
jobsRoot,
|
|
name,
|
|
resultRecord(
|
|
job,
|
|
{
|
|
status: "failed",
|
|
reason: `http ${response.status}: ${text.slice(0, 2000)}`,
|
|
},
|
|
nowIso(),
|
|
),
|
|
);
|
|
return "failed";
|
|
}
|
|
|
|
await finishJob(
|
|
jobsRoot,
|
|
name,
|
|
resultRecord(
|
|
job,
|
|
{
|
|
status: "ok",
|
|
durationMs: Date.now() - startedAt,
|
|
response: JSON.parse(text),
|
|
},
|
|
nowIso(),
|
|
),
|
|
);
|
|
return "ok";
|
|
}
|
|
|
|
export async function runWorker({ jobsRoot, services, opts, stopSignal }) {
|
|
await ensureQueueDirs(jobsRoot);
|
|
let state = createWorkerState();
|
|
process.stdout.write(
|
|
`[queue] worker up — jobs: ${jobsRoot}, services: ${Object.entries(services)
|
|
.map(([model, url]) => `${model}=${url}`)
|
|
.join(", ")}\n`,
|
|
);
|
|
|
|
while (!stopSignal.stopped) {
|
|
const pendingDir = path.join(jobsRoot, "pending");
|
|
const pending = claimOrder(await fs.readdir(pendingDir));
|
|
|
|
if (pending.length === 0) {
|
|
if (opts.once) return state;
|
|
await new Promise((resolve) => setTimeout(resolve, opts.pollMs));
|
|
continue;
|
|
}
|
|
|
|
// While unreachable (draining or not), probe health first so recovery is
|
|
// detected without burning a job attempt, and drain time keeps accruing
|
|
// even when no fetch is attempted.
|
|
if (state.unreachableSince !== null) {
|
|
try {
|
|
await probeServices(services, Math.min(opts.requestTimeoutMs, 5000));
|
|
state = onServiceOk();
|
|
process.stdout.write(
|
|
"[queue] gpu service reachable again — resuming\n",
|
|
);
|
|
} catch {
|
|
// error-policy:J1 probe failure is the state machine's input, not an
|
|
// error to escalate: it advances unreachableSince toward drain mode.
|
|
state = onServiceUnreachable(state, Date.now(), opts.drainAfterMs);
|
|
}
|
|
}
|
|
|
|
for (const name of pending) {
|
|
if (stopSignal.stopped) return state;
|
|
const processingPath = path.join(jobsRoot, "processing", name);
|
|
let raw;
|
|
try {
|
|
await fs.rename(path.join(pendingDir, name), processingPath);
|
|
raw = await fs.readFile(processingPath, "utf8");
|
|
} catch (err) {
|
|
if (err.code === "ENOENT") continue; // claimed by a concurrent worker
|
|
throw err;
|
|
}
|
|
const outcome = await processClaimed(
|
|
jobsRoot,
|
|
name,
|
|
raw,
|
|
services,
|
|
state,
|
|
opts,
|
|
);
|
|
if (outcome === "unreachable") {
|
|
state = onServiceUnreachable(state, Date.now(), opts.drainAfterMs);
|
|
break; // stop hammering; retry after the poll interval / drain instead
|
|
}
|
|
if (outcome === "ok" || outcome === "failed") state = onServiceOk();
|
|
}
|
|
|
|
if (opts.once) {
|
|
// --once runs until the queue is empty; jobs bounced back by an outage
|
|
// keep the loop alive until they either succeed or drain to skips.
|
|
const left = claimOrder(await fs.readdir(pendingDir));
|
|
if (left.length === 0) return state;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, opts.pollMs));
|
|
}
|
|
return state;
|
|
}
|
|
|
|
async function main() {
|
|
const argv = process.argv.slice(2);
|
|
const jobsIdx = argv.indexOf("--jobs");
|
|
if (jobsIdx === -1 || !argv[jobsIdx + 1]) {
|
|
throw new Error("[queue] --jobs <dir> is required");
|
|
}
|
|
const jobsRoot = path.resolve(argv[jobsIdx + 1]);
|
|
const services = parseServices(argv);
|
|
const opts = {
|
|
pollMs: flagValue(argv, "--poll-ms", DEFAULT_LIMITS.pollMs),
|
|
drainAfterMs: flagValue(
|
|
argv,
|
|
"--drain-after-ms",
|
|
DEFAULT_LIMITS.drainAfterMs,
|
|
),
|
|
requestTimeoutMs: flagValue(
|
|
argv,
|
|
"--request-timeout-ms",
|
|
DEFAULT_LIMITS.requestTimeoutMs,
|
|
),
|
|
once: argv.includes("--once"),
|
|
};
|
|
|
|
const stopSignal = { stopped: false };
|
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
process.on(signal, () => {
|
|
process.stdout.write(
|
|
`[queue] ${signal} — finishing current job then exiting\n`,
|
|
);
|
|
stopSignal.stopped = true;
|
|
});
|
|
}
|
|
|
|
await runWorker({ jobsRoot, services, opts, stopSignal });
|
|
process.stdout.write("[queue] worker stopped\n");
|
|
}
|
|
|
|
if (
|
|
process.argv[1] &&
|
|
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
main().catch((err) => {
|
|
process.stderr.write(`${err.stack || err.message}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|