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
614 lines
23 KiB
TypeScript
614 lines
23 KiB
TypeScript
/**
|
|
* Memory Arbiter (WS1) tests.
|
|
*
|
|
* Covers:
|
|
* - Capability registration + acquire/release refcounting.
|
|
* - Same-modelKey concurrent acquires share one in-flight load.
|
|
* - Conflicting modelKey on the same role triggers an evict-then-load swap.
|
|
* - In-flight runs delay swaps (refcount > 0 waits).
|
|
* - Pressure events: low → one eviction; critical → wipe non-text.
|
|
* - Critical pressure rejects acquire for non-text capabilities.
|
|
* - Telemetry: model_load / model_unload / memory_pressure / eviction / capability_run.
|
|
* - Vision-embedding cache: hit, miss, expiry, LRU eviction.
|
|
* - Composite/poll pressure source emit nominal at boot and respond to source updates.
|
|
* - Shutdown unloads everything.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type ArbiterEvent,
|
|
MemoryArbiter,
|
|
type CapabilityRegistration,
|
|
} from "../src/services/memory-arbiter";
|
|
import {
|
|
type MemoryPressureEvent,
|
|
capacitorPressureSource,
|
|
compositePressureSource,
|
|
nodeOsPressureSource,
|
|
} from "../src/services/memory-pressure";
|
|
import { VisionEmbeddingCache } from "../src/services/vision-embedding-cache";
|
|
import { SharedResourceRegistry } from "../src/services/voice/shared-resources";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface FakeBackend {
|
|
id: string;
|
|
disposed: boolean;
|
|
}
|
|
|
|
function makeFakeCapability(
|
|
overrides: Partial<CapabilityRegistration<FakeBackend, { x: string }, string>> = {},
|
|
): {
|
|
registration: CapabilityRegistration<FakeBackend, { x: string }, string>;
|
|
loaded: string[];
|
|
unloaded: string[];
|
|
loadDelayMs: { value: number };
|
|
runDelayMs: { value: number };
|
|
} {
|
|
const loaded: string[] = [];
|
|
const unloaded: string[] = [];
|
|
const loadDelayMs = { value: 0 };
|
|
const runDelayMs = { value: 0 };
|
|
const registration: CapabilityRegistration<FakeBackend, { x: string }, string> = {
|
|
capability: "text",
|
|
residentRole: "text-target",
|
|
estimatedMb: 1024,
|
|
load: async (modelKey) => {
|
|
loaded.push(modelKey);
|
|
if (loadDelayMs.value > 0) {
|
|
await new Promise<void>((r) => setTimeout(r, loadDelayMs.value));
|
|
}
|
|
return { id: modelKey, disposed: false };
|
|
},
|
|
unload: async (backend) => {
|
|
unloaded.push(backend.id);
|
|
backend.disposed = true;
|
|
},
|
|
run: async (backend, req) => {
|
|
if (runDelayMs.value > 0) {
|
|
await new Promise<void>((r) => setTimeout(r, runDelayMs.value));
|
|
}
|
|
if (backend.disposed) throw new Error(`backend ${backend.id} already disposed`);
|
|
return `${backend.id}:${req.x}`;
|
|
},
|
|
...overrides,
|
|
};
|
|
return { registration, loaded, unloaded, loadDelayMs, runDelayMs };
|
|
}
|
|
|
|
function makeArbiter(opts: { capacitorBridge?: ReturnType<typeof capacitorPressureSource> } = {}): {
|
|
arbiter: MemoryArbiter;
|
|
registry: SharedResourceRegistry;
|
|
bridge: ReturnType<typeof capacitorPressureSource>;
|
|
events: ArbiterEvent[];
|
|
} {
|
|
const registry = new SharedResourceRegistry();
|
|
const bridge = opts.capacitorBridge ?? capacitorPressureSource();
|
|
const arbiter = new MemoryArbiter({
|
|
registry,
|
|
pressureSource: bridge,
|
|
visionCache: new VisionEmbeddingCache({ config: { maxEntries: 4, defaultTtlMs: 60_000 } }),
|
|
});
|
|
const events: ArbiterEvent[] = [];
|
|
arbiter.onEvent((event) => events.push(event));
|
|
arbiter.start();
|
|
return { arbiter, registry, bridge, events };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Capability registration + acquire/release
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — registration and acquire", () => {
|
|
it("rejects acquire when no capability is registered", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
await expect(arbiter.acquire("vision-describe", "gemma-vl-4b")).rejects.toThrow(
|
|
/no capability registered/,
|
|
);
|
|
});
|
|
|
|
it("rejects duplicate registration", () => {
|
|
const { arbiter } = makeArbiter();
|
|
const a = makeFakeCapability();
|
|
const b = makeFakeCapability();
|
|
arbiter.registerCapability(a.registration);
|
|
expect(() => arbiter.registerCapability(b.registration)).toThrow(/already registered/);
|
|
});
|
|
|
|
it("loads on first acquire and reuses the handle on the second", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const cap = makeFakeCapability();
|
|
arbiter.registerCapability(cap.registration);
|
|
const h1 = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
const h2 = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
expect(cap.loaded).toEqual(["tier-2b"]);
|
|
expect(h1.backend.id).toBe("tier-2b");
|
|
expect(h2.backend).toBe(h1.backend);
|
|
await h1.release();
|
|
await h2.release();
|
|
expect(cap.unloaded).toEqual([]); // refcount=0 alone does not unload
|
|
});
|
|
|
|
it("shares a single in-flight load across concurrent acquires", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const cap = makeFakeCapability();
|
|
cap.loadDelayMs.value = 30;
|
|
arbiter.registerCapability(cap.registration);
|
|
const [h1, h2, h3] = await Promise.all([
|
|
arbiter.acquire<FakeBackend>("text", "shared-tier"),
|
|
arbiter.acquire<FakeBackend>("text", "shared-tier"),
|
|
arbiter.acquire<FakeBackend>("text", "shared-tier"),
|
|
]);
|
|
expect(cap.loaded).toEqual(["shared-tier"]);
|
|
expect(h1.backend).toBe(h2.backend);
|
|
expect(h2.backend).toBe(h3.backend);
|
|
await h1.release();
|
|
await h2.release();
|
|
await h3.release();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Swap behaviour: same role, different modelKey
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — swap on conflicting role", () => {
|
|
it("evicts the previous model when the same role is loaded for a different key", async () => {
|
|
const { arbiter, events } = makeArbiter();
|
|
const cap = makeFakeCapability();
|
|
arbiter.registerCapability(cap.registration);
|
|
const a = await arbiter.acquire<FakeBackend>("text", "tier-a");
|
|
await a.release();
|
|
const b = await arbiter.acquire<FakeBackend>("text", "tier-b");
|
|
await b.release();
|
|
expect(cap.loaded).toEqual(["tier-a", "tier-b"]);
|
|
expect(cap.unloaded).toEqual(["tier-a"]);
|
|
const evictionEvents = events.filter((e) => e.type === "eviction");
|
|
expect(evictionEvents).toHaveLength(1);
|
|
expect(evictionEvents[0]).toMatchObject({
|
|
capability: "text",
|
|
modelKey: "tier-a",
|
|
reason: "swap",
|
|
});
|
|
});
|
|
|
|
it("waits for in-flight refcount holders before swapping", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const cap = makeFakeCapability();
|
|
cap.runDelayMs.value = 25;
|
|
arbiter.registerCapability(cap.registration);
|
|
const a = await arbiter.acquire<FakeBackend>("text", "tier-a");
|
|
// Hold the handle and start a fake long-running op via run() against the
|
|
// same backend.
|
|
const longRun = (async () => {
|
|
// Simulate work that retains the handle.
|
|
await new Promise<void>((r) => setTimeout(r, 30));
|
|
await a.release();
|
|
})();
|
|
// Now request the other model. It must NOT evict tier-a until the
|
|
// longRun above releases.
|
|
const swapStart = Date.now();
|
|
const b = await arbiter.acquire<FakeBackend>("text", "tier-b");
|
|
const swapMs = Date.now() - swapStart;
|
|
expect(cap.unloaded).toEqual(["tier-a"]);
|
|
// We waited at least roughly the in-flight delay; allow scheduling slack.
|
|
expect(swapMs).toBeGreaterThanOrEqual(20);
|
|
await b.release();
|
|
await longRun;
|
|
});
|
|
|
|
it("loads two different roles concurrently without swapping", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
const t = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
const v = await arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b");
|
|
expect(text.unloaded).toEqual([]);
|
|
expect(vision.unloaded).toEqual([]);
|
|
await t.release();
|
|
await v.release();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pressure-driven eviction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — memory pressure", () => {
|
|
it("evicts the lowest-priority resident role on `low` pressure", async () => {
|
|
const { arbiter, bridge, events } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
const t = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
const v = await arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b");
|
|
await t.release();
|
|
await v.release();
|
|
// Vision is priority 20 (cheaper) than text-target (100); pressure
|
|
// should evict vision.
|
|
bridge.dispatch("low");
|
|
// Allow the async handler to drain.
|
|
await new Promise<void>((r) => setTimeout(r, 5));
|
|
expect(vision.unloaded).toEqual(["gemma-vl-4b"]);
|
|
expect(text.unloaded).toEqual([]);
|
|
const pressureEvents = events.filter((e) => e.type === "memory_pressure");
|
|
expect(pressureEvents.at(-1)?.level).toBe("low");
|
|
});
|
|
|
|
it("evicts every non-text role on `critical` pressure", async () => {
|
|
const { arbiter, bridge } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
const embedding = makeFakeCapability({
|
|
capability: "embedding",
|
|
residentRole: "embedding",
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
arbiter.registerCapability(embedding.registration);
|
|
const t = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
const v = await arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b");
|
|
const e = await arbiter.acquire<FakeBackend>("embedding", "embedding-small");
|
|
await t.release();
|
|
await v.release();
|
|
await e.release();
|
|
bridge.dispatch("critical");
|
|
await new Promise<void>((r) => setTimeout(r, 5));
|
|
expect(vision.unloaded).toEqual(["gemma-vl-4b"]);
|
|
expect(embedding.unloaded).toEqual(["embedding-small"]);
|
|
// text-target survives critical
|
|
expect(text.unloaded).toEqual([]);
|
|
});
|
|
|
|
it("rejects non-text acquires under critical pressure", async () => {
|
|
const { arbiter, bridge } = makeArbiter();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
arbiter.registerCapability(vision.registration);
|
|
bridge.dispatch("critical");
|
|
await new Promise<void>((r) => setTimeout(r, 5));
|
|
await expect(
|
|
arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b"),
|
|
).rejects.toThrow(/critical/);
|
|
});
|
|
|
|
it("still allows text acquires under critical pressure", async () => {
|
|
const { arbiter, bridge } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
arbiter.registerCapability(text.registration);
|
|
bridge.dispatch("critical");
|
|
await new Promise<void>((r) => setTimeout(r, 5));
|
|
const t = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
expect(t.backend.id).toBe("tier-2b");
|
|
await t.release();
|
|
});
|
|
|
|
it("does not evict a role with refcount > 0 under pressure", async () => {
|
|
const { arbiter, bridge } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
const t = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
const v = await arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b");
|
|
// Hold onto vision (refcount=1)
|
|
await t.release();
|
|
bridge.dispatch("critical");
|
|
await new Promise<void>((r) => setTimeout(r, 5));
|
|
expect(vision.unloaded).toEqual([]);
|
|
await v.release();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Capability request queue (the request* methods)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — request queue", () => {
|
|
it("runs sequential requests against the same loaded handle", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
arbiter.registerCapability(text.registration);
|
|
const r1 = await arbiter.requestText<{ x: string }, string>({
|
|
modelKey: "tier-2b",
|
|
payload: { x: "alpha" },
|
|
});
|
|
const r2 = await arbiter.requestText<{ x: string }, string>({
|
|
modelKey: "tier-2b",
|
|
payload: { x: "beta" },
|
|
});
|
|
expect(r1).toBe("tier-2b:alpha");
|
|
expect(r2).toBe("tier-2b:beta");
|
|
expect(text.loaded).toEqual(["tier-2b"]);
|
|
});
|
|
|
|
it("emits capability_run telemetry per request", async () => {
|
|
const { arbiter, events } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
arbiter.registerCapability(text.registration);
|
|
await arbiter.requestText({ modelKey: "tier-2b", payload: { x: "y" } });
|
|
const runs = events.filter((e) => e.type === "capability_run");
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]).toMatchObject({ capability: "text", modelKey: "tier-2b" });
|
|
});
|
|
|
|
it("propagates run errors back to the caller without leaking the handle", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability({
|
|
run: async () => {
|
|
throw new Error("boom");
|
|
},
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
await expect(
|
|
arbiter.requestText({ modelKey: "tier-2b", payload: { x: "y" } }),
|
|
).rejects.toThrow(/boom/);
|
|
// Despite the error the next request must still run cleanly: no
|
|
// dangling refcount, no permanent eviction.
|
|
const text2 = makeFakeCapability({
|
|
capability: "embedding",
|
|
residentRole: "embedding",
|
|
});
|
|
arbiter.registerCapability(text2.registration);
|
|
const out = await arbiter.requestEmbedding<{ x: string }, string>({
|
|
modelKey: "embed-1",
|
|
payload: { x: "z" },
|
|
});
|
|
expect(out).toBe("embed-1:z");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Vision-embedding cache passthrough
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — vision embedding cache", () => {
|
|
it("returns null on miss and the entry on hit", () => {
|
|
const { arbiter } = makeArbiter();
|
|
expect(arbiter.getCachedVisionEmbedding("missing")).toBeNull();
|
|
const tokens = new Float32Array(8);
|
|
arbiter.setCachedVisionEmbedding("h1", { tokens, tokenCount: 2, hiddenSize: 4 });
|
|
const hit = arbiter.getCachedVisionEmbedding("h1");
|
|
expect(hit?.tokenCount).toBe(2);
|
|
expect(hit?.hiddenSize).toBe(4);
|
|
expect(hit?.tokens).toBe(tokens);
|
|
});
|
|
});
|
|
|
|
describe("VisionEmbeddingCache", () => {
|
|
it("evicts LRU when capacity is exceeded", () => {
|
|
const cache = new VisionEmbeddingCache({ config: { maxEntries: 2, defaultTtlMs: 60_000 } });
|
|
cache.set("a", { tokens: new Float32Array(2), tokenCount: 1, hiddenSize: 2 });
|
|
cache.set("b", { tokens: new Float32Array(2), tokenCount: 1, hiddenSize: 2 });
|
|
// Touch a so it becomes most recently used.
|
|
expect(cache.get("a")).not.toBeNull();
|
|
cache.set("c", { tokens: new Float32Array(2), tokenCount: 1, hiddenSize: 2 });
|
|
expect(cache.get("b")).toBeNull(); // b was the LRU
|
|
expect(cache.get("a")).not.toBeNull();
|
|
expect(cache.get("c")).not.toBeNull();
|
|
});
|
|
|
|
it("returns null after TTL expiry and purges via purgeExpired", () => {
|
|
let nowMs = 1000;
|
|
const cache = new VisionEmbeddingCache({
|
|
config: { maxEntries: 8, defaultTtlMs: 100 },
|
|
now: () => nowMs,
|
|
});
|
|
cache.set("a", { tokens: new Float32Array(2), tokenCount: 1, hiddenSize: 2 });
|
|
nowMs = 200; // not expired yet (1000 + 100 = 1100; 200 < 1100? wait — re-derive)
|
|
// Actually expiry uses now+ttl at insertion. Recompute deterministically.
|
|
// a expires at 1000 + 100 = 1100. Bump now past that.
|
|
nowMs = 1500;
|
|
expect(cache.get("a")).toBeNull();
|
|
// Re-insert and purge.
|
|
cache.set("b", { tokens: new Float32Array(2), tokenCount: 1, hiddenSize: 2 });
|
|
nowMs = 1500 + 200;
|
|
const removed = cache.purgeExpired();
|
|
expect(removed).toBe(1);
|
|
expect(cache.size()).toBe(0);
|
|
});
|
|
|
|
it("rejects mismatched token buffer length", () => {
|
|
const cache = new VisionEmbeddingCache();
|
|
expect(() =>
|
|
cache.set("bad", { tokens: new Float32Array(3), tokenCount: 2, hiddenSize: 2 }),
|
|
).toThrow(/does not match/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pressure source contracts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("nodeOsPressureSource", () => {
|
|
it("emits nominal when free memory is comfortably above the low-water line", () => {
|
|
const events: MemoryPressureEvent[] = [];
|
|
const source = nodeOsPressureSource(
|
|
{ intervalMs: 1_000, lowWaterFraction: 0.15, criticalWaterFraction: 0.05 },
|
|
{ osMemory: () => ({ freeBytes: 8 * 1024 ** 3, totalBytes: 16 * 1024 ** 3 }) },
|
|
);
|
|
const unsub = source.subscribe((e) => events.push(e));
|
|
source.start();
|
|
expect(events.at(-1)?.level).toBe("nominal");
|
|
unsub();
|
|
source.stop();
|
|
});
|
|
|
|
it("transitions to low and critical as free memory drops", () => {
|
|
let freeBytes = 8 * 1024 ** 3;
|
|
const source = nodeOsPressureSource(
|
|
{ intervalMs: 1_000, lowWaterFraction: 0.2, criticalWaterFraction: 0.05 },
|
|
{ osMemory: () => ({ freeBytes, totalBytes: 16 * 1024 ** 3 }) },
|
|
);
|
|
expect(source.current().level).toBe("nominal");
|
|
freeBytes = 2 * 1024 ** 3; // 12.5% free → below 20% low-water
|
|
expect(source.current().level).toBe("low");
|
|
freeBytes = 0.5 * 1024 ** 3; // ~3% → below critical
|
|
expect(source.current().level).toBe("critical");
|
|
});
|
|
|
|
it("rejects criticalWaterFraction >= lowWaterFraction at construction", () => {
|
|
expect(() =>
|
|
nodeOsPressureSource({ lowWaterFraction: 0.1, criticalWaterFraction: 0.2 }),
|
|
).toThrow(/criticalWaterFraction/);
|
|
});
|
|
});
|
|
|
|
describe("capacitorPressureSource", () => {
|
|
it("dispatches the level the native bridge sends", () => {
|
|
const source = capacitorPressureSource();
|
|
const events: MemoryPressureEvent[] = [];
|
|
source.subscribe((e) => events.push(e));
|
|
source.start();
|
|
source.dispatch("low", 256);
|
|
source.dispatch("critical", 64);
|
|
const levels = events.map((e) => e.level);
|
|
expect(levels).toContain("low");
|
|
expect(levels).toContain("critical");
|
|
});
|
|
});
|
|
|
|
describe("compositePressureSource", () => {
|
|
it("reports the worst level across underlying sources", () => {
|
|
const a = capacitorPressureSource();
|
|
const b = capacitorPressureSource();
|
|
const composite = compositePressureSource([a, b]);
|
|
composite.start();
|
|
const events: MemoryPressureEvent[] = [];
|
|
composite.subscribe((e) => events.push(e));
|
|
a.dispatch("low");
|
|
b.dispatch("critical");
|
|
expect(events.at(-1)?.level).toBe("critical");
|
|
// Recovery from critical only when *both* report nominal.
|
|
b.dispatch("nominal");
|
|
expect(events.at(-1)?.level).toBe("low");
|
|
a.dispatch("nominal");
|
|
expect(events.at(-1)?.level).toBe("nominal");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Concurrency edge cases (self-criticism scenarios)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — concurrency edge cases", () => {
|
|
it("does NOT serialize across different capabilities", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
text.runDelayMs.value = 30;
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
const start = Date.now();
|
|
// Fire a slow text request, then a vision request. Different
|
|
// capabilities have separate queues — they should overlap.
|
|
const [t, v] = await Promise.all([
|
|
arbiter.requestText<{ x: string }, string>({
|
|
modelKey: "tier-2b",
|
|
payload: { x: "slow" },
|
|
}),
|
|
arbiter.requestVisionDescribe<{ x: string }, string>({
|
|
modelKey: "gemma-vl-4b",
|
|
payload: { x: "fast" },
|
|
}),
|
|
]);
|
|
const elapsed = Date.now() - start;
|
|
expect(t).toBe("tier-2b:slow");
|
|
expect(v).toBe("gemma-vl-4b:fast");
|
|
// If queues were shared, total would be ~30ms (text) + ~0ms (vision) =
|
|
// 30ms. With separate queues we expect at most max(loadtimes)+max(runtimes),
|
|
// still bounded — assert specifically that vision did not block on text.
|
|
expect(elapsed).toBeLessThan(200);
|
|
});
|
|
|
|
it("survives a pressure event arriving during a load", async () => {
|
|
const { arbiter, bridge } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
vision.loadDelayMs.value = 40;
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
// Start a slow vision load.
|
|
const visionAcquire = arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b");
|
|
// While it's mid-flight, dispatch low pressure. The arbiter should
|
|
// process the event without crashing the load.
|
|
bridge.dispatch("low");
|
|
await new Promise<void>((r) => setTimeout(r, 5));
|
|
const handle = await visionAcquire;
|
|
expect(handle.backend.id).toBe("gemma-vl-4b");
|
|
await handle.release();
|
|
});
|
|
|
|
it("redundant release() is idempotent and does not double-decrement", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
arbiter.registerCapability(text.registration);
|
|
const h = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
await h.release();
|
|
await h.release(); // no-op
|
|
// Re-acquire should not need to reload — the role is still warm.
|
|
const h2 = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
expect(text.loaded).toEqual(["tier-2b"]);
|
|
await h2.release();
|
|
});
|
|
|
|
it("retain() throws after release()", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
arbiter.registerCapability(text.registration);
|
|
const h = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
await h.release();
|
|
expect(() => h.retain()).toThrow(/cannot retain/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shutdown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryArbiter — shutdown", () => {
|
|
it("unloads every resident handle and rejects new acquires", async () => {
|
|
const { arbiter } = makeArbiter();
|
|
const text = makeFakeCapability();
|
|
const vision = makeFakeCapability({
|
|
capability: "vision-describe",
|
|
residentRole: "vision",
|
|
});
|
|
arbiter.registerCapability(text.registration);
|
|
arbiter.registerCapability(vision.registration);
|
|
const t = await arbiter.acquire<FakeBackend>("text", "tier-2b");
|
|
const v = await arbiter.acquire<FakeBackend>("vision-describe", "gemma-vl-4b");
|
|
await t.release();
|
|
await v.release();
|
|
await arbiter.shutdown();
|
|
expect(text.unloaded).toContain("tier-2b");
|
|
expect(vision.unloaded).toContain("gemma-vl-4b");
|
|
await expect(arbiter.acquire("text", "tier-2b")).rejects.toThrow(/shutting down/);
|
|
});
|
|
});
|