evOo-local/packages/adapters/src/wakeup.postgres.test.ts
Claude c60c693d13
feat(media): media job runtime, media queue and media settings
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
2026-10-03 13:31:53 +00:00

207 lines
6.7 KiB
TypeScript

import type { BackgroundJobHandlers } from "@rakazo/adapter-kit";
import { Pool } from "pg";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GraphileJobPublisher, GraphileJobWorkerHost } from "./wakeup.js";
const databaseUrl = process.env.DATABASE_URL;
const describePostgres =
process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip;
let pool: Pool | undefined;
function testPool(): Pool {
pool ??= new Pool({ connectionString: databaseUrl, max: 4 });
return pool;
}
afterEach(async () => {
await pool?.end();
pool = undefined;
});
function handlers(overrides: Partial<BackgroundJobHandlers> = {}): 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),
...overrides,
};
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function waitFor(assertion: () => void, timeoutMs = 10_000): Promise<void> {
await vi.waitFor(assertion, { timeout: timeoutMs, interval: 25 });
}
describePostgres("Graphile background jobs (PostgreSQL contract)", () => {
it("waits for an active handler during graceful shutdown", async () => {
const publisher = new GraphileJobPublisher(testPool());
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
const entered = deferred();
const release = deferred();
let completed = false;
const target = handlers({
"run.continue": async () => {
entered.resolve();
await release.promise;
completed = true;
},
});
try {
await host.start(target);
await publisher.enqueue({ name: "run.continue", payload: { runId: "graceful-stop" } });
await entered.promise;
let stopped = false;
const stopping = host.stop().then(() => {
stopped = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(stopped).toBe(false);
release.resolve();
await stopping;
expect(completed).toBe(true);
} finally {
release.resolve();
await host.stop();
await publisher.close();
}
});
it("replaces a keyed delayed job without running the old payload or schedule", async () => {
const publisher = new GraphileJobPublisher(testPool());
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
const received: string[] = [];
const target = handlers({
"computer.sleep": async ({ computerId }) => {
received.push(computerId);
},
});
const key = `contract:replacement:${Date.now()}`;
try {
await host.start(target);
await publisher.enqueue({
name: "computer.sleep",
payload: { computerId: "old" },
availableAt: new Date(Date.now() + 300),
replaceKey: key,
});
await publisher.enqueue({
name: "computer.sleep",
payload: { computerId: "replacement" },
availableAt: new Date(Date.now() + 800),
replaceKey: key,
});
await new Promise((resolve) => setTimeout(resolve, 500));
expect(received).toEqual([]);
await waitFor(() => expect(received).toEqual(["replacement"]));
} finally {
await host.stop();
await publisher.cancel(key).catch(() => undefined);
await publisher.close();
}
});
it("removes a keyed delayed job before a worker can run it", async () => {
const publisher = new GraphileJobPublisher(testPool());
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
const target = handlers();
const key = `contract:cancellation:${Date.now()}`;
try {
await host.start(target);
await publisher.enqueue({
name: "computer.sleep",
payload: { computerId: "cancelled" },
availableAt: new Date(Date.now() + 300),
replaceKey: key,
});
await publisher.cancel(key);
await new Promise((resolve) => setTimeout(resolve, 600));
expect(target["computer.sleep"]).not.toHaveBeenCalled();
} finally {
await host.stop();
await publisher.close();
}
});
it("runs jobs that share a queue one at a time across workers", async () => {
const publisher = new GraphileJobPublisher(testPool());
const first = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
const second = new GraphileJobWorkerHost(testPool(), { concurrency: 2, pollInterval: 25 });
const queue = `contract-queue-${Date.now()}`;
let active = 0;
let maxActive = 0;
const done: string[] = [];
const target = handlers({
"media.run": async ({ jobId }) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 150));
active -= 1;
done.push(jobId);
},
});
try {
await first.start(target);
await second.start(target);
for (const jobId of ["q1", "q2", "q3"]) {
await publisher.enqueue({ name: "media.run", payload: { jobId }, queue });
}
await waitFor(() => expect(done).toHaveLength(3));
expect(maxActive).toBe(1);
} finally {
await first.stop();
await second.stop();
await publisher.close();
}
});
it("persists a handler failure and retries the job successfully", async () => {
const publisher = new GraphileJobPublisher(testPool());
const host = new GraphileJobWorkerHost(testPool(), { concurrency: 1, pollInterval: 25 });
let attempts = 0;
const target = handlers({
"run.continue": async () => {
attempts += 1;
if (attempts === 1) throw new Error("intentional contract-test failure");
},
});
const key = `contract:retry:${Date.now()}`;
try {
await host.start(target);
await publisher.enqueue({
name: "run.continue",
payload: { runId: "retry" },
replaceKey: key,
});
await waitFor(() => expect(attempts).toBe(2), 15_000);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(attempts).toBe(2);
} finally {
await host.stop();
await publisher.cancel(key).catch(() => undefined);
await publisher.close();
}
});
});