426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
420 lines
13 KiB
JavaScript
Executable File
420 lines
13 KiB
JavaScript
Executable File
/**
|
|
* Tests for the GPU job queue (#14549): the pure state machine in
|
|
* queue-lib.mjs (job validation, claim order, backpressure, unreachable→drain
|
|
* transitions, result records), and the real worker process end-to-end — a
|
|
* spawned queue-worker.mjs consuming a temp jobs dir against a live in-test
|
|
* HTTP stub standing in for the llama-server endpoint (the GPU model itself is
|
|
* exercised on the owner-gated Linux acceptance run), including the
|
|
* drain-to-skip path against an unreachable port. Run with
|
|
* `bun test docker/certification`.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
claimOrder,
|
|
createWorkerState,
|
|
decideEnqueue,
|
|
IMAGE_PLACEHOLDER,
|
|
makeJobId,
|
|
onServiceOk,
|
|
onServiceUnreachable,
|
|
parseJob,
|
|
QueueJobInvalidError,
|
|
resolveImagePlaceholders,
|
|
resultRecord,
|
|
shouldSkipJob,
|
|
} from "./queue-lib.mjs";
|
|
import { enqueueJob, QueueBackpressureError } from "./queue-worker.mjs";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const workerPath = path.join(here, "queue-worker.mjs");
|
|
|
|
describe("parseJob", () => {
|
|
const models = ["ocr", "vlm"];
|
|
test("accepts a well-formed job", () => {
|
|
const job = parseJob(
|
|
JSON.stringify({ id: "a-1", model: "ocr", request: { messages: [] } }),
|
|
models,
|
|
);
|
|
expect(job.id).toBe("a-1");
|
|
});
|
|
|
|
test("rejects malformed JSON, missing fields, unknown models, unsafe paths", () => {
|
|
expect(() => parseJob("{nope", models)).toThrow(QueueJobInvalidError);
|
|
expect(() => parseJob('"str"', models)).toThrow(/not an object/);
|
|
expect(() => parseJob("{}", models)).toThrow(/missing id/);
|
|
expect(() =>
|
|
parseJob(
|
|
JSON.stringify({ id: "a/../b", model: "ocr", request: {} }),
|
|
models,
|
|
),
|
|
).toThrow(/unsafe characters/);
|
|
expect(() =>
|
|
parseJob(JSON.stringify({ id: "a", model: "nope", request: {} }), models),
|
|
).toThrow(/model must be one of ocr\|vlm/);
|
|
expect(() =>
|
|
parseJob(JSON.stringify({ id: "a", model: "ocr" }), models),
|
|
).toThrow(/missing request/);
|
|
expect(() =>
|
|
parseJob(
|
|
JSON.stringify({
|
|
id: "a",
|
|
model: "ocr",
|
|
request: {},
|
|
imagePath: "../etc/passwd",
|
|
}),
|
|
models,
|
|
),
|
|
).toThrow(/without \.\./);
|
|
expect(() =>
|
|
parseJob(
|
|
JSON.stringify({
|
|
id: "a",
|
|
model: "ocr",
|
|
request: {},
|
|
imagePath: "/abs",
|
|
}),
|
|
models,
|
|
),
|
|
).toThrow(/relative/);
|
|
});
|
|
});
|
|
|
|
describe("claim order and ids", () => {
|
|
test("claims oldest-first by timestamp-prefixed id, json files only", () => {
|
|
const names = [
|
|
"20260706T2-b.json",
|
|
"20260706T1-a.json",
|
|
"junk.tmp",
|
|
"20260706T3-c.json",
|
|
];
|
|
expect(claimOrder(names)).toEqual([
|
|
"20260706T1-a.json",
|
|
"20260706T2-b.json",
|
|
"20260706T3-c.json",
|
|
]);
|
|
});
|
|
|
|
test("makeJobId sorts by enqueue time", () => {
|
|
const early = makeJobId(Date.UTC(2026, 6, 6, 10, 0, 0), "aaa");
|
|
const late = makeJobId(Date.UTC(2026, 6, 6, 10, 0, 1), "aaa");
|
|
expect(early < late).toBe(true);
|
|
expect(early).toMatch(/^[0-9]+-aaa$/);
|
|
});
|
|
});
|
|
|
|
describe("backpressure", () => {
|
|
test("accepts under the cap, refuses at the cap with a reason", () => {
|
|
expect(decideEnqueue(0, 2)).toEqual({ accept: true });
|
|
expect(decideEnqueue(1, 2)).toEqual({ accept: true });
|
|
expect(decideEnqueue(2, 2).accept).toBe(false);
|
|
expect(decideEnqueue(2, 2).reason).toMatch(/2 pending >= max 2/);
|
|
});
|
|
});
|
|
|
|
describe("unreachable → drain state machine", () => {
|
|
test("stamps the outage start, drains only past the window, resets on contact", () => {
|
|
const drainAfterMs = 1000;
|
|
let state = createWorkerState();
|
|
expect(shouldSkipJob(state)).toBe(false);
|
|
|
|
state = onServiceUnreachable(state, 10_000, drainAfterMs);
|
|
expect(state.unreachableSince).toBe(10_000);
|
|
expect(state.draining).toBe(false);
|
|
|
|
state = onServiceUnreachable(state, 10_500, drainAfterMs);
|
|
expect(state.unreachableSince).toBe(10_000); // outage start is preserved
|
|
expect(state.draining).toBe(false);
|
|
|
|
state = onServiceUnreachable(state, 11_000, drainAfterMs);
|
|
expect(state.draining).toBe(true);
|
|
expect(shouldSkipJob(state)).toBe(true);
|
|
|
|
// Draining latches until the service actually answers again.
|
|
state = onServiceUnreachable(state, 11_001, drainAfterMs);
|
|
expect(state.draining).toBe(true);
|
|
|
|
state = onServiceOk();
|
|
expect(state).toEqual({ unreachableSince: null, draining: false });
|
|
});
|
|
});
|
|
|
|
describe("resultRecord", () => {
|
|
const job = { id: "j1", model: "ocr" };
|
|
test("ok carries duration + response; failed/skipped carry a reason", () => {
|
|
const ok = resultRecord(
|
|
job,
|
|
{ status: "ok", durationMs: 42, response: { x: 1 } },
|
|
"t",
|
|
);
|
|
expect(ok).toEqual({
|
|
schema: 1,
|
|
id: "j1",
|
|
model: "ocr",
|
|
status: "ok",
|
|
completedAt: "t",
|
|
durationMs: 42,
|
|
response: { x: 1 },
|
|
});
|
|
const skipped = resultRecord(
|
|
job,
|
|
{ status: "skipped", reason: "drained" },
|
|
"t",
|
|
);
|
|
expect(skipped.reason).toBe("drained");
|
|
expect(skipped.response).toBeUndefined();
|
|
expect(() => resultRecord(job, { status: "wat" }, "t")).toThrow(
|
|
/unknown outcome/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("resolveImagePlaceholders", () => {
|
|
test("replaces only the placeholder url, without mutating the input", () => {
|
|
const request = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "image_url", image_url: { url: IMAGE_PLACEHOLDER } },
|
|
{ type: "text", text: `keep ${IMAGE_PLACEHOLDER} in text` },
|
|
{ type: "image_url", image_url: { url: "data:already/inline" } },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const resolved = resolveImagePlaceholders(
|
|
request,
|
|
"data:image/png;base64,AA==",
|
|
);
|
|
expect(resolved.messages[0].content[0].image_url.url).toBe(
|
|
"data:image/png;base64,AA==",
|
|
);
|
|
expect(resolved.messages[0].content[1].text).toContain(IMAGE_PLACEHOLDER);
|
|
expect(resolved.messages[0].content[2].image_url.url).toBe(
|
|
"data:already/inline",
|
|
);
|
|
expect(request.messages[0].content[0].image_url.url).toBe(
|
|
IMAGE_PLACEHOLDER,
|
|
);
|
|
});
|
|
});
|
|
|
|
/** Spawn the real worker in --once mode and wait for exit. */
|
|
function runWorkerOnce(jobsRoot, serviceUrl, extraArgs = []) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
workerPath,
|
|
"--jobs",
|
|
jobsRoot,
|
|
"--service",
|
|
`ocr=${serviceUrl}`,
|
|
"--service",
|
|
`vlm=${serviceUrl}`,
|
|
"--poll-ms",
|
|
"50",
|
|
"--once",
|
|
...extraArgs,
|
|
],
|
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => resolve({ code, stdout, stderr }));
|
|
});
|
|
}
|
|
|
|
describe("worker end-to-end (real process, stub llama-server)", () => {
|
|
let server;
|
|
let baseUrl;
|
|
const seen = [];
|
|
|
|
beforeAll(async () => {
|
|
server = http.createServer((req, res) => {
|
|
if (req.url === "/health") {
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end('{"status":"ok"}');
|
|
return;
|
|
}
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk;
|
|
});
|
|
req.on("end", () => {
|
|
const parsed = JSON.parse(body);
|
|
seen.push({ url: req.url, body: parsed });
|
|
if (parsed.messages?.[0]?.content === "explode") {
|
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
res.end("kaboom");
|
|
return;
|
|
}
|
|
res.writeHead(200, { "content-type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
choices: [
|
|
{ message: { role: "assistant", content: "stub-answer" } },
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 2 },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.close();
|
|
});
|
|
|
|
test("consumes enqueued jobs, calls the service, writes ok/failed results beside them", async () => {
|
|
const jobsRoot = await fs.mkdtemp(path.join(os.tmpdir(), "eliza-queue-"));
|
|
const okId = await enqueueJob(jobsRoot, {
|
|
model: "ocr",
|
|
request: { messages: [{ role: "user", content: "read this" }] },
|
|
});
|
|
const failId = await enqueueJob(jobsRoot, {
|
|
model: "vlm",
|
|
request: { messages: [{ role: "user", content: "explode" }] },
|
|
});
|
|
// Invalid job file dropped straight into pending/ (bypasses enqueueJob).
|
|
await fs.writeFile(
|
|
path.join(jobsRoot, "pending", "zzz-invalid.json"),
|
|
"{nope",
|
|
"utf8",
|
|
);
|
|
|
|
const { code, stderr } = await runWorkerOnce(jobsRoot, baseUrl);
|
|
expect(stderr).toBe("");
|
|
expect(code).toBe(0);
|
|
|
|
const okResult = JSON.parse(
|
|
await fs.readFile(path.join(jobsRoot, "results", `${okId}.json`), "utf8"),
|
|
);
|
|
expect(okResult.status).toBe("ok");
|
|
expect(okResult.response.choices[0].message.content).toBe("stub-answer");
|
|
expect(okResult.durationMs).toBeGreaterThanOrEqual(0);
|
|
|
|
const failResult = JSON.parse(
|
|
await fs.readFile(
|
|
path.join(jobsRoot, "results", `${failId}.json`),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(failResult.status).toBe("failed");
|
|
expect(failResult.reason).toMatch(/http 500: kaboom/);
|
|
|
|
const invalidResult = JSON.parse(
|
|
await fs.readFile(
|
|
path.join(jobsRoot, "results", "zzz-invalid.json"),
|
|
"utf8",
|
|
),
|
|
);
|
|
expect(invalidResult.status).toBe("failed");
|
|
expect(invalidResult.reason).toMatch(/not valid JSON/);
|
|
|
|
// Consumed jobs moved to done/, pending drained.
|
|
expect((await fs.readdir(path.join(jobsRoot, "pending"))).length).toBe(0);
|
|
expect((await fs.readdir(path.join(jobsRoot, "done"))).sort().length).toBe(
|
|
3,
|
|
);
|
|
await fs.rm(jobsRoot, { recursive: true, force: true });
|
|
}, 30_000);
|
|
|
|
test("inlines imagePath as a data URI at the queue:image placeholder", async () => {
|
|
const jobsRoot = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "eliza-queue-img-"),
|
|
);
|
|
await fs.mkdir(path.join(jobsRoot, "images"), { recursive: true });
|
|
// 1x1 PNG.
|
|
const png = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
await fs.writeFile(path.join(jobsRoot, "images", "shot.png"), png);
|
|
seen.length = 0;
|
|
const id = await enqueueJob(jobsRoot, {
|
|
model: "ocr",
|
|
imagePath: "images/shot.png",
|
|
request: {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "image_url", image_url: { url: IMAGE_PLACEHOLDER } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const { code } = await runWorkerOnce(jobsRoot, baseUrl);
|
|
expect(code).toBe(0);
|
|
const result = JSON.parse(
|
|
await fs.readFile(path.join(jobsRoot, "results", `${id}.json`), "utf8"),
|
|
);
|
|
expect(result.status).toBe("ok");
|
|
const sent = seen[0].body.messages[0].content[0].image_url.url;
|
|
expect(sent).toBe(`data:image/png;base64,${png.toString("base64")}`);
|
|
await fs.rm(jobsRoot, { recursive: true, force: true });
|
|
}, 30_000);
|
|
|
|
test("enqueueJob applies max-pending backpressure", async () => {
|
|
const jobsRoot = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "eliza-queue-bp-"),
|
|
);
|
|
const limits = { maxPending: 2 };
|
|
await enqueueJob(jobsRoot, { model: "ocr", request: {} }, limits);
|
|
await enqueueJob(jobsRoot, { model: "ocr", request: {} }, limits);
|
|
await expect(
|
|
enqueueJob(jobsRoot, { model: "ocr", request: {} }, limits),
|
|
).rejects.toThrow(QueueBackpressureError);
|
|
await fs.rm(jobsRoot, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("worker drain-to-skip (unreachable service, real process)", () => {
|
|
test("past the drain window every pending job becomes an honest skipped record", async () => {
|
|
const jobsRoot = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "eliza-queue-drain-"),
|
|
);
|
|
const ids = [];
|
|
for (let i = 0; i < 3; i++) {
|
|
ids.push(
|
|
await enqueueJob(jobsRoot, { model: "ocr", request: { messages: [] } }),
|
|
);
|
|
}
|
|
// Port 1 on localhost: connection refused immediately, nobody listens.
|
|
const { code } = await runWorkerOnce(jobsRoot, "http://127.0.0.1:1", [
|
|
"--drain-after-ms",
|
|
"300",
|
|
"--request-timeout-ms",
|
|
"500",
|
|
]);
|
|
expect(code).toBe(0);
|
|
for (const id of ids) {
|
|
const result = JSON.parse(
|
|
await fs.readFile(path.join(jobsRoot, "results", `${id}.json`), "utf8"),
|
|
);
|
|
expect(result.status).toBe("skipped");
|
|
expect(result.reason).toMatch(/unreachable past 300ms — drained/);
|
|
}
|
|
expect((await fs.readdir(path.join(jobsRoot, "pending"))).length).toBe(0);
|
|
await fs.rm(jobsRoot, { recursive: true, force: true });
|
|
}, 30_000);
|
|
});
|