evOo-local/packages/adapters/src/job-reconciler.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

421 lines
14 KiB
TypeScript

import {
type JobPublisher,
messagingDeliverJob,
routineWakeupJob,
runContinueJob,
} from "@rakazo/adapter-kit";
import type { MessageBlock } from "@rakazo/contracts";
import type { Pool, PrismaClient, ThreadEvents } from "@rakazo/db";
import { getLogger } from "@rakazo/logging";
import type { PoolClient } from "pg";
import { returnBotMessageOutcome } from "./bot-messages.js";
import { scheduleComputerControlExpiry } from "./computer-control.js";
import { isUserProgressClientNonce } from "./user-progress.js";
const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 100;
const ROUTINE_LOOKAHEAD_MS = 60_000;
const CONTROL_LOOKAHEAD_MS = 60_000;
// Two keys give Rakazo's lock a namespace without relying on a hash that might collide
// with an application using the one-key advisory-lock API.
const RECONCILIATION_LOCK_NAMESPACE = 1_380_019_075;
const RECONCILIATION_LOCK_ID = 1;
type Cursor = { at: Date; id: string };
type ControlCursor = { at: Date | null; id: string };
export interface ReconciliationLeadership {
tryAcquire(): Promise<boolean>;
release(): Promise<void>;
}
/**
* Holds a session advisory lock for the lifetime of the elected reconciler. Followers
* retry on every reconciliation interval, so a disconnected or stopped leader is
* replaced without coordinating through application state.
*/
export function createPostgresReconciliationLeadership(
pool: Pick<Pool, "connect">,
options: { lockId?: number } = {},
): ReconciliationLeadership {
const lockId = options.lockId ?? RECONCILIATION_LOCK_ID;
let leaderClient: PoolClient | undefined;
let leaderErrorListener: (() => void) | undefined;
const loseClient = (client: PoolClient) => {
if (leaderClient !== client) return;
leaderClient = undefined;
leaderErrorListener = undefined;
client.release(true);
};
return {
async tryAcquire() {
if (leaderClient) return true;
const candidate = await pool.connect();
try {
const result = await candidate.query<{ acquired: boolean }>(
"SELECT pg_try_advisory_lock($1::integer, $2::integer) AS acquired",
[RECONCILIATION_LOCK_NAMESPACE, lockId],
);
if (!result.rows[0]?.acquired) {
candidate.release();
return false;
}
leaderClient = candidate;
leaderErrorListener = () => loseClient(candidate);
candidate.once("error", leaderErrorListener);
return true;
} catch (error) {
candidate.release(true);
throw error;
}
},
async release() {
const client = leaderClient;
if (!client) return;
leaderClient = undefined;
if (leaderErrorListener) client.removeListener("error", leaderErrorListener);
leaderErrorListener = undefined;
let destroy = false;
try {
const result = await client.query<{ released: boolean }>(
"SELECT pg_advisory_unlock($1::integer, $2::integer) AS released",
[RECONCILIATION_LOCK_NAMESPACE, lockId],
);
destroy = result.rows[0]?.released !== true;
} catch {
// Never return a connection with an uncertain session lock to the pool.
destroy = true;
} finally {
client.release(destroy);
}
},
};
}
export function createJobReconciler(
deps: {
prisma: PrismaClient;
jobs: JobPublisher;
events?: ThreadEvents;
leadership?: ReconciliationLeadership;
reconcileComputerUpdates?: () => Promise<void>;
reconcileCloudAgents?: () => Promise<void>;
/** Enqueues daily profile consolidation; see createProfileConsolidationScheduler. */
reconcileProfileConsolidation?: () => Promise<void>;
reconcileDelegatedTasks?: () => Promise<void>;
reconcileMediaJobs?: () => Promise<void>;
},
options: { intervalMs?: number; batchSize?: number } = {},
) {
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
let timer: ReturnType<typeof setInterval> | undefined;
let reconciling: Promise<void> | undefined;
let runCursor: Cursor | undefined;
let routineCursor: Cursor | undefined;
let controlCursor: ControlCursor | undefined;
let controlScanDeadline: Date | undefined;
const reconcileOnce = async () => {
if (reconciling) return reconciling;
reconciling = (async () => {
if (deps.leadership && !(await deps.leadership.tryAcquire())) return;
const auxiliary = await Promise.allSettled(
[
deps.reconcileCloudAgents,
deps.reconcileComputerUpdates,
deps.reconcileProfileConsolidation,
deps.reconcileDelegatedTasks,
deps.reconcileMediaJobs,
].map(async (reconcile) => reconcile?.()),
);
for (const result of auxiliary) {
if (result.status === "rejected")
getLogger().error("auxiliary reconciliation", result.reason);
}
const now = new Date();
controlScanDeadline ??= new Date(now.getTime() + CONTROL_LOOKAHEAD_MS);
const runCursorFilter = runCursor
? {
OR: [
{ updatedAt: { gt: runCursor.at } },
{ updatedAt: runCursor.at, id: { gt: runCursor.id } },
],
}
: undefined;
const routineCursorFilter = routineCursor
? {
OR: [
{ nextRunAt: { gt: routineCursor.at } },
{ nextRunAt: routineCursor.at, id: { gt: routineCursor.id } },
],
}
: undefined;
const controlCursorFilter = controlCursor
? controlCursor.at
? {
OR: [
{ controlLeaseExpiresAt: { gt: controlCursor.at } },
{
controlLeaseExpiresAt: controlCursor.at,
id: { gt: controlCursor.id },
},
{ controlLeaseExpiresAt: null },
],
}
: { controlLeaseExpiresAt: null, id: { gt: controlCursor.id } }
: undefined;
const [runs, routines, controls, dueOutbound, unmirroredMessagingRuns] = await Promise.all([
deps.prisma.run.findMany({
where: {
AND: [
{
OR: [
{ status: "queued" },
{
status: { in: ["leased", "running"] },
leaseExpiresAt: { lte: now },
},
],
},
...(runCursorFilter ? [runCursorFilter] : []),
],
},
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: batchSize,
select: { id: true, updatedAt: true },
}),
deps.prisma.routine.findMany({
where: {
AND: [
{
active: true,
nextRunAt: { lte: new Date(now.getTime() + ROUTINE_LOOKAHEAD_MS) },
},
...(routineCursorFilter ? [routineCursorFilter] : []),
],
},
orderBy: [{ nextRunAt: "asc" }, { id: "asc" }],
take: batchSize,
select: { id: true, nextRunAt: true },
}),
deps.prisma.computer.findMany({
where: {
AND: [
{ controlLeaseId: { not: null } },
{
OR: [
{ controlLeaseExpiresAt: null },
{
controlLeaseExpiresAt: {
lte: controlScanDeadline,
},
},
],
},
...(controlCursorFilter ? [controlCursorFilter] : []),
],
},
orderBy: [{ controlLeaseExpiresAt: "asc" }, { id: "asc" }],
take: batchSize,
select: {
id: true,
controlBotId: true,
controlLeaseId: true,
controlLeaseExpiresAt: true,
},
}),
deps.prisma.messagingOutbound.findFirst({
where: {
status: "pending",
OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: now } }],
},
select: { id: true },
}),
deps.prisma.run.findMany({
where: { trigger: "messaging", status: "completed", messagingMirroredAt: null },
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: batchSize,
select: { id: true },
}),
]);
const events = deps.events;
if (events) {
const outcomes = await deps.prisma.run.findMany({
where: {
trigger: "bot_message",
status: { in: ["completed", "failed"] },
botOutcomeReturnedAt: null,
},
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
take: batchSize,
select: {
id: true,
spaceId: true,
threadId: true,
botId: true,
userId: true,
sourceMessageId: true,
status: true,
error: true,
bot: { select: { name: true } },
},
});
await Promise.all(
outcomes.map(async (run) => {
const transcript =
run.status === "failed"
? { text: "", progressOnly: false }
: await botRunOutcomeText(deps.prisma, run.id);
const text =
run.status === "failed"
? `Could not complete the delegated request: ${run.error ?? "unknown error"}`
: transcript.text ||
"The delegated bot completed its turn without a written summary.";
// Same stable delivery key as the executor path (auto-outcome:<runId>), so a
// concurrent or earlier return is replayed instead of double-posted. Progress-only
// transcripts (all mid-turn user-progress messages) return as status.
const intent =
run.status === "failed" || !transcript.text.trim() || transcript.progressOnly
? "status"
: ("result" as const);
const returned = await returnBotMessageOutcome(
{ prisma: deps.prisma, jobs: deps.jobs, events },
run,
{ id: run.botId, name: run.bot.name },
text,
intent,
).catch((error) => {
getLogger().error("bot message outcome reconciliation", error);
return false;
});
if (!returned) {
await deps.prisma.run.updateMany({
where: { id: run.id, botOutcomeReturnedAt: null },
data: { updatedAt: new Date() },
});
}
}),
);
}
await Promise.all([
...runs.map((run) => deps.jobs.enqueue(runContinueJob(run.id))),
...routines.flatMap((routine) =>
routine.nextRunAt
? [deps.jobs.enqueue(routineWakeupJob(routine.id, routine.nextRunAt))]
: [],
),
...controls.flatMap((computer) =>
computer.controlLeaseId
? [
scheduleComputerControlExpiry(
deps.jobs,
computer.id,
computer.controlLeaseId,
computer.controlLeaseExpiresAt ?? now,
),
]
: [],
),
...(dueOutbound ? [deps.jobs.enqueue(messagingDeliverJob())] : []),
...unmirroredMessagingRuns.map((run) => deps.jobs.enqueue(messagingDeliverJob(run.id))),
]);
const lastRun = runs.at(-1);
runCursor =
runs.length === batchSize && lastRun
? { at: lastRun.updatedAt, id: lastRun.id }
: undefined;
const lastRoutine = routines.at(-1);
routineCursor =
routines.length === batchSize && lastRoutine?.nextRunAt
? { at: lastRoutine.nextRunAt, id: lastRoutine.id }
: undefined;
const lastControl = controls.at(-1);
controlCursor =
controls.length === batchSize && lastControl
? { at: lastControl.controlLeaseExpiresAt, id: lastControl.id }
: undefined;
if (!controlCursor) controlScanDeadline = undefined;
})().finally(() => {
reconciling = undefined;
});
return reconciling;
};
const reconcileSafely = () => {
void reconcileOnce().catch((error) =>
getLogger().error("background job reconciliation", error),
);
};
return {
reconcileOnce,
start() {
if (timer) return;
reconcileSafely();
timer = setInterval(reconcileSafely, intervalMs);
timer.unref?.();
},
async stop() {
if (timer) clearInterval(timer);
timer = undefined;
await reconciling?.catch(() => undefined);
await deps.leadership?.release();
},
};
}
/** Prefer the full bot transcript for a run so interim progress is not mistaken for the sole result. */
export async function botRunOutcomeText(
prisma: {
message: {
findMany: (args: {
where: { runId: string; role: "bot" };
orderBy: { seq: "asc" };
select: { blocks: true; clientNonce: true };
}) => Promise<Array<{ blocks: unknown; clientNonce: string | null }>>;
};
},
runId: string,
): Promise<{ text: string; progressOnly: boolean }> {
const messages = await prisma.message.findMany({
where: { runId, role: "bot" },
orderBy: { seq: "asc" },
select: { blocks: true, clientNonce: true },
});
const progressParts: string[] = [];
const finalParts: string[] = [];
for (const message of messages) {
const text = messageText(message.blocks);
if (!text) continue;
if (isUserProgressClientNonce(message.clientNonce)) progressParts.push(text);
else finalParts.push(text);
}
// Prefer the latest non-progress reply when present so earlier untagged mid-run
// publishes (for example pre-takeover narration) do not contaminate the result.
// Progress-only turns still join progress beats as status.
if (finalParts.length > 0) {
return { text: finalParts[finalParts.length - 1]!, progressOnly: false };
}
return { text: progressParts.join("\n\n"), progressOnly: progressParts.length > 0 };
}
function messageText(blocks: unknown): string {
if (!Array.isArray(blocks)) return "";
return (blocks as MessageBlock[])
.filter((block): block is Extract<MessageBlock, { kind: "text" }> => block.kind === "text")
.map((block) => block.text)
.join("")
.trim();
}