Media operations (generation, edits, transcription) need a runner that works
outside the agent turn, keeps a card in the conversation current and never
lets two processes put work on one GPU. Operations land in later commits; this
is the runtime they register with.
- Background job `media.run { jobId }` on a named `media` queue. Jobs carry an
optional queue: the Graphile publisher passes it as queueName and the
in-memory driver runs each queue one job at a time, in order.
- `createMediaRuntime` implements MediaRuntime (capabilities, start, get,
cancel) over a list of `MediaOperationRegistration`s and runs jobs:
start validates the input, creates the job and posts the bot's media card in
one transaction (idempotent per tool call through an operation key), then
enqueues it. The runner claims a fenced lease (an advisory lock keeps one
live lease at a time, so the GPU stays single even when the queue is
bypassed), renews it while polling for cancellation, writes progress and
outputs into the card, defers with backoff when the GPU is short of memory
or the operation asks to wait, bounds deferrals, crash restarts and run
time, always cleans the job's temp directory and always ends the card in a
terminal state. With continue_after the starting bot is woken in the same
transaction that ends the job (new run trigger `media`).
- The reconciler re-enqueues jobs whose enqueue was lost, whose runner died
or whose wait is over; a job stuck behind a dead worker's queue lock runs
outside the queue.
- Media backends are deployment-wide settings stored encrypted as the
`media` IntegrationProviderConfig row: `loadMediaSettings` and
`saveMediaSettings` with the same address policy as OpenAI-compatible
servers (no metadata hosts, public hosts only when allowed, HTTPS when a key
is sent), a saved key never follows the settings to another server, and the
view never returns keys. Contract in contracts/media-settings.ts.
- `createMediaOperations` is the one list both the API and the worker build
their runtime from (empty until the operations land), and media-tools.ts has
the shared `continue_after` schema helper for the tool definitions.
- Migration: lease, deferral, operation key and continueAfter columns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
import type { BackgroundJobHandlers } from "@rakazo/adapter-kit";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { InMemoryJobQueue } from "./wakeup.js";
|
|
|
|
function handlers(): BackgroundJobHandlers {
|
|
return {
|
|
"run.continue": vi.fn(async () => undefined),
|
|
"routine.wakeup": vi.fn(async () => undefined),
|
|
"computer.update": vi.fn(async () => undefined),
|
|
"computer.sleep": vi.fn(async () => undefined),
|
|
"computer.control-expire": vi.fn(async () => undefined),
|
|
"skill.teaching-expire": vi.fn(async () => undefined),
|
|
"history.compact": vi.fn(async () => undefined),
|
|
"messaging.deliver": vi.fn(async () => undefined),
|
|
"cloud_agent.poll": vi.fn(async () => undefined),
|
|
"profile.consolidate": vi.fn(async () => undefined),
|
|
"media.run": vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
describe("InMemoryJobQueue", () => {
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("delivers delayed jobs", async () => {
|
|
vi.useFakeTimers();
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await queue.enqueue({
|
|
name: "run.continue",
|
|
payload: { runId: "run-1" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(target["run.continue"]).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(target["run.continue"]).toHaveBeenCalledWith({ runId: "run-1" });
|
|
await queue.close();
|
|
});
|
|
|
|
it("replaces and cancels keyed jobs", async () => {
|
|
vi.useFakeTimers();
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "old" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
replaceKey: "computer.sleep:1",
|
|
});
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "new" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
replaceKey: "computer.sleep:1",
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledTimes(1);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledWith({ computerId: "new" });
|
|
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "cancelled" },
|
|
availableAt: new Date(Date.now() + 1_000),
|
|
replaceKey: "computer.sleep:2",
|
|
});
|
|
await queue.cancel("computer.sleep:2");
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledTimes(1);
|
|
await queue.close();
|
|
});
|
|
|
|
it.each([
|
|
["stop", "stop"],
|
|
["close", "close"],
|
|
["stop", "close"],
|
|
["close", "stop"],
|
|
] as const)("waits for an active handler before %s/%s", async (first, second) => {
|
|
const queue = new InMemoryJobQueue();
|
|
let releaseHandler: () => void = () => undefined;
|
|
let markStarted: () => void = () => undefined;
|
|
let nestedEnqueueFailed = false;
|
|
let delayedEnqueueFailed = false;
|
|
let chainedEnqueueFailed = false;
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const target = handlers();
|
|
target["computer.sleep"] = vi.fn(async () => {
|
|
try {
|
|
await queue.enqueue({ name: "run.continue", payload: { runId: "chained-run" } });
|
|
} catch {
|
|
chainedEnqueueFailed = true;
|
|
}
|
|
});
|
|
target["run.continue"] = vi.fn(async () => {
|
|
markStarted();
|
|
await new Promise<void>((resolve) => {
|
|
releaseHandler = resolve;
|
|
});
|
|
try {
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "computer-1" },
|
|
replaceKey: "keep-during-close",
|
|
});
|
|
await queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "cancelled-computer" },
|
|
replaceKey: "cancel-during-close",
|
|
});
|
|
await queue.cancel("cancel-during-close");
|
|
} catch {
|
|
nestedEnqueueFailed = true;
|
|
}
|
|
try {
|
|
await queue.enqueue({
|
|
name: "routine.wakeup",
|
|
payload: { routineId: "routine-1", scheduledFor: "2099-01-01T00:00:00.000Z" },
|
|
availableAt: new Date("2099-01-01T00:00:00.000Z"),
|
|
});
|
|
} catch {
|
|
delayedEnqueueFailed = true;
|
|
}
|
|
});
|
|
await queue.start(target);
|
|
await queue.enqueue({ name: "run.continue", payload: { runId: "run-1" } });
|
|
await started;
|
|
|
|
const closing = Promise.all([queue[first](), queue[second]()] as const);
|
|
const closeState = await Promise.race([
|
|
closing.then(() => "closed" as const),
|
|
new Promise<"waiting">((resolve) => setTimeout(() => resolve("waiting"), 5)),
|
|
]);
|
|
releaseHandler();
|
|
await closing;
|
|
expect(closeState).toBe("waiting");
|
|
expect(nestedEnqueueFailed).toBe(false);
|
|
expect(delayedEnqueueFailed).toBe(false);
|
|
expect(chainedEnqueueFailed).toBe(true);
|
|
expect(target["computer.sleep"]).toHaveBeenCalledTimes(1);
|
|
expect(target["run.continue"]).toHaveBeenCalledTimes(1);
|
|
expect(target["routine.wakeup"]).not.toHaveBeenCalled();
|
|
await queue.close();
|
|
});
|
|
|
|
it("rejects a keyed enqueue that resumes after close", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
let releaseCancel: () => void = () => undefined;
|
|
let markCancelling: () => void = () => undefined;
|
|
const cancelling = new Promise<void>((resolve) => {
|
|
markCancelling = resolve;
|
|
});
|
|
vi.spyOn(queue, "cancel").mockImplementationOnce(async () => {
|
|
markCancelling();
|
|
await new Promise<void>((resolve) => {
|
|
releaseCancel = resolve;
|
|
});
|
|
});
|
|
|
|
const enqueue = queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "computer-1" },
|
|
replaceKey: "computer:computer-1",
|
|
});
|
|
await cancelling;
|
|
await queue.close();
|
|
releaseCancel();
|
|
|
|
await expect(enqueue).rejects.toThrow("Background job publisher is closed");
|
|
});
|
|
|
|
it("rejects a keyed enqueue that resumes after stop", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
await queue.start(handlers());
|
|
let releaseCancel: () => void = () => undefined;
|
|
let markCancelling: () => void = () => undefined;
|
|
const cancelling = new Promise<void>((resolve) => {
|
|
markCancelling = resolve;
|
|
});
|
|
vi.spyOn(queue, "cancel").mockImplementationOnce(async () => {
|
|
markCancelling();
|
|
await new Promise<void>((resolve) => {
|
|
releaseCancel = resolve;
|
|
});
|
|
});
|
|
|
|
const enqueue = queue.enqueue({
|
|
name: "computer.sleep",
|
|
payload: { computerId: "computer-1" },
|
|
replaceKey: "computer:computer-1",
|
|
});
|
|
await cancelling;
|
|
await queue.stop();
|
|
releaseCancel();
|
|
|
|
await expect(enqueue).rejects.toThrow("Background job publisher is stopped");
|
|
});
|
|
|
|
it("runs jobs of one named queue one at a time, in order", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const order: string[] = [];
|
|
let active = 0;
|
|
let maxActive = 0;
|
|
const target = handlers();
|
|
target["media.run"] = async ({ jobId }) => {
|
|
active += 1;
|
|
maxActive = Math.max(maxActive, active);
|
|
order.push(`start:${jobId}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
order.push(`end:${jobId}`);
|
|
active -= 1;
|
|
};
|
|
target["computer.sleep"] = async () => {
|
|
order.push("unqueued");
|
|
};
|
|
await queue.start(target);
|
|
|
|
for (const jobId of ["a", "b", "c"]) {
|
|
await queue.enqueue({ name: "media.run", payload: { jobId }, queue: "media" });
|
|
}
|
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
|
await queue.close();
|
|
|
|
expect(maxActive).toBe(1);
|
|
expect(order.filter((entry) => entry !== "unqueued")).toEqual([
|
|
"start:a",
|
|
"end:a",
|
|
"start:b",
|
|
"end:b",
|
|
"start:c",
|
|
"end:c",
|
|
]);
|
|
// Jobs outside the queue do not wait for it.
|
|
expect(order.indexOf("unqueued")).toBeLessThan(order.indexOf("end:a"));
|
|
});
|
|
|
|
it("keeps a named queue moving after a job fails", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const ran: string[] = [];
|
|
const target = handlers();
|
|
target["media.run"] = async ({ jobId }) => {
|
|
ran.push(jobId);
|
|
if (jobId === "a") throw new Error("boom");
|
|
};
|
|
await queue.start(target);
|
|
await queue.enqueue({ name: "media.run", payload: { jobId: "a" }, queue: "media" });
|
|
await queue.enqueue({ name: "media.run", payload: { jobId: "b" }, queue: "media" });
|
|
await queue.close();
|
|
expect(ran).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("drains an accepted immediate timer during close", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
|
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
|
await queue.close();
|
|
|
|
expect(target["computer.sleep"]).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("rejects jobs after stop until the worker restarts", async () => {
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await queue.stop();
|
|
|
|
await expect(
|
|
queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } }),
|
|
).rejects.toThrow("Background job publisher is stopped");
|
|
|
|
await queue.start(target);
|
|
await queue.enqueue({ name: "computer.sleep", payload: { computerId: "computer-1" } });
|
|
await queue.close();
|
|
expect(target["computer.sleep"]).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("unwraps correlation envelopes and restores request traces", async () => {
|
|
const { createLogger, createTestSink, installLogger, runWithLogContext } = await import(
|
|
"@rakazo/logging"
|
|
);
|
|
const sink = createTestSink();
|
|
installLogger(createLogger({ service: "rakazo-worker", sinks: [sink] }));
|
|
const queue = new InMemoryJobQueue();
|
|
const target = handlers();
|
|
await queue.start(target);
|
|
await runWithLogContext(
|
|
{ "trace.id": "a".repeat(32), "span.id": "b".repeat(16), "request.id": "req-1" },
|
|
async () => {
|
|
await queue.enqueue({ name: "run.continue", payload: { runId: "run-traced" } });
|
|
},
|
|
);
|
|
await queue.close();
|
|
expect(target["run.continue"]).toHaveBeenCalledWith({ runId: "run-traced" });
|
|
expect(sink.events.some((event) => event.message === "job.completed")).toBe(true);
|
|
expect(
|
|
sink.events.some(
|
|
(event) => event["trace.id"] === "a".repeat(32) && event["run.id"] === "run-traced",
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("keeps wrapped payloads readable by workers that do not unwrap envelopes", async () => {
|
|
const { wrapJobPayload } = await import("@rakazo/logging");
|
|
const { parseBackgroundJob } = await import("@rakazo/adapter-kit");
|
|
const wrapped = wrapJobPayload(
|
|
{ runId: "run-rollback" },
|
|
{ jobId: "job-1", traceId: "a".repeat(32) },
|
|
);
|
|
expect(parseBackgroundJob("run.continue", wrapped)).toEqual({
|
|
name: "run.continue",
|
|
payload: { runId: "run-rollback" },
|
|
});
|
|
});
|
|
});
|