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
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
import type { JobPublisher, JobWorkerHost } from "@rakazo/adapter-kit";
|
|
import { ComposioConnector, IntegrationProviderSettings } from "@rakazo/adapters";
|
|
import { loadRootEnv } from "@rakazo/core/node/load-root-env";
|
|
|
|
loadRootEnv();
|
|
|
|
import {
|
|
ChatSdkMessagingSurface,
|
|
createBackgroundJobHandlers,
|
|
createCloudAgentConnection,
|
|
createConnectorStack,
|
|
createJobReconciler,
|
|
createMediaOperations,
|
|
createMediaRuntime,
|
|
createMessagingContextLoader,
|
|
createPostgresReconciliationLeadership,
|
|
createProfileConsolidationScheduler,
|
|
createRunExecutor,
|
|
createRunSandbox,
|
|
createRunSecretWriter,
|
|
createWebProvider,
|
|
databaseCapacityBackoffMs,
|
|
EncryptedSecretStore,
|
|
ExpoPushProvider,
|
|
GraphileJobPublisher,
|
|
GraphileJobWorkerHost,
|
|
InMemoryJobQueue,
|
|
InstalledConnectorProvider,
|
|
isComposioEnabled,
|
|
isMessagingSurfaceEnabled,
|
|
isPipedreamEnabled,
|
|
LocalAgentHomeStore,
|
|
LocalArtifactStore,
|
|
loadMediaSettings,
|
|
McpConnector,
|
|
McpOAuthBroker,
|
|
messagingEnvFromProcess,
|
|
messagingPlatformsFromEnv,
|
|
PiAgentRuntime,
|
|
PipedreamConnector,
|
|
PostgresRealtimeFanout,
|
|
pipedreamConfigFromEnv,
|
|
reconcileCloudAgents,
|
|
reconcileComputerUpdates,
|
|
reconcileDelegatedTasks,
|
|
resolveDeploymentModel,
|
|
resolvePiSessionRoot,
|
|
resolveSandboxProvider,
|
|
ScriptedAgentRuntime,
|
|
SpaceMemoryProviderResolver,
|
|
} from "@rakazo/adapters";
|
|
import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core";
|
|
import {
|
|
createDb,
|
|
createThreadEvents,
|
|
isTooManyDatabaseConnections,
|
|
parsePositiveInteger,
|
|
} from "@rakazo/db";
|
|
import { SERVICE_NAMES } from "@rakazo/logging";
|
|
import { createRootLogger } from "@rakazo/logging/axiom";
|
|
import { MarkdownMemoryStore } from "@rakazo/memory";
|
|
|
|
const logger = createRootLogger(SERVICE_NAMES.worker);
|
|
|
|
async function main() {
|
|
const databaseUrl = process.env.DATABASE_URL;
|
|
if (!databaseUrl) throw new Error("DATABASE_URL is required");
|
|
// Shared by Prisma, the reconciliation leadership lock, and both graphile-worker
|
|
// components (see GraphileJobPublisher/GraphileJobWorkerHost) — one pool instead
|
|
// of four separate ones. Keep this modest: graphile holds a LISTEN client and
|
|
// leadership holds an advisory-lock client for the process lifetime, and a
|
|
// larger max just competes for Postgres max_connections (53300).
|
|
const { prisma, pool } = createDb(databaseUrl, {
|
|
poolMax: parsePositiveInteger(process.env.DB_POOL_MAX, 8),
|
|
applicationName: "rakazo-worker",
|
|
});
|
|
const realtime = new PostgresRealtimeFanout({
|
|
connectionString: process.env.REALTIME_DATABASE_URL ?? databaseUrl,
|
|
publisher: pool,
|
|
});
|
|
const secrets = new EncryptedSecretStore(resolveEncryptionKey(process.env));
|
|
const events = createThreadEvents(prisma, realtime, {
|
|
runSecretWriter: createRunSecretWriter(secrets),
|
|
});
|
|
const dataDir = process.env.DATA_DIR ?? "./data";
|
|
const runtime =
|
|
process.env.AGENT_RUNTIME === "scripted"
|
|
? new ScriptedAgentRuntime()
|
|
: new PiAgentRuntime({ sessionRoot: resolvePiSessionRoot(dataDir) });
|
|
// Same resolver the API uses, so both processes agree on provider, model and key.
|
|
const { key: deploymentModelKey } = resolveDeploymentModel();
|
|
const sandboxProvider = resolveSandboxProvider(process.env);
|
|
const sandbox = createRunSandbox(sandboxProvider, {
|
|
supervisorUrl: process.env.SANDBOX_SUPERVISOR_URL ?? "http://127.0.0.1:7091",
|
|
supervisorToken: sandboxProvider === "docker" ? resolveSupervisorToken(process.env) : undefined,
|
|
e2bApiKey: process.env.E2B_API_KEY,
|
|
daytonaApiKey: process.env.DAYTONA_API_KEY,
|
|
daytonaApiUrl: process.env.DAYTONA_API_URL,
|
|
daytonaTarget: process.env.DAYTONA_TARGET,
|
|
boxApiKey: process.env.BOX_API_KEY,
|
|
boxApiUrl: process.env.BOX_API_URL ?? process.env.BOX_BASE_URL,
|
|
dataDir,
|
|
prisma,
|
|
});
|
|
const mcpOAuth = new McpOAuthBroker(prisma, secrets);
|
|
const mcp = new McpConnector(
|
|
prisma,
|
|
secrets,
|
|
{
|
|
stdioEnabled: process.env.MCP_STDIO_ENABLED === "true",
|
|
allowedCommands: (process.env.MCP_STDIO_ALLOWED_COMMANDS ?? "")
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter(Boolean),
|
|
},
|
|
mcpOAuth,
|
|
);
|
|
const pipedreamConfig = pipedreamConfigFromEnv({
|
|
pipedreamClientId: process.env.PIPEDREAM_CLIENT_ID,
|
|
pipedreamClientSecret: process.env.PIPEDREAM_CLIENT_SECRET,
|
|
pipedreamProjectId: process.env.PIPEDREAM_PROJECT_ID,
|
|
pipedreamEnvironment: process.env.PIPEDREAM_ENVIRONMENT,
|
|
encryptionKey: resolveEncryptionKey(process.env),
|
|
});
|
|
const pipedream = isPipedreamEnabled(pipedreamConfig)
|
|
? new PipedreamConnector(pipedreamConfig)
|
|
: undefined;
|
|
// pollInboundMessages stays false (the default) here: this process
|
|
// only ever sends outbound (messaging.deliver jobs). It must never poll
|
|
// Telegram — that would steal the single getUpdates slot away from the
|
|
// API process, which is the one with the inbound sink actually wired up.
|
|
const messagingPlatforms = messagingPlatformsFromEnv(messagingEnvFromProcess(process.env));
|
|
const messaging = isMessagingSurfaceEnabled(messagingPlatforms, {
|
|
deploymentModelKey,
|
|
openSignup: process.env.MESSAGING_OPEN_SIGNUP === "true",
|
|
})
|
|
? new ChatSdkMessagingSurface(messagingPlatforms)
|
|
: undefined;
|
|
const integrationSettings = new IntegrationProviderSettings(
|
|
prisma,
|
|
secrets,
|
|
resolveEncryptionKey(process.env),
|
|
{
|
|
composio: isComposioEnabled(process.env.COMPOSIO_API_KEY)
|
|
? new ComposioConnector(process.env.COMPOSIO_API_KEY)
|
|
: undefined,
|
|
pipedream,
|
|
},
|
|
);
|
|
const stack = createConnectorStack(false, undefined, [
|
|
new InstalledConnectorProvider(prisma, secrets),
|
|
...integrationSettings.providers(),
|
|
mcp,
|
|
]);
|
|
const connector = stack.destination;
|
|
await connector.start();
|
|
integrationSettings.warmDirectories();
|
|
const memoryProviders = new SpaceMemoryProviderResolver(prisma, secrets);
|
|
const home = new LocalAgentHomeStore(dataDir);
|
|
const artifacts = new LocalArtifactStore(dataDir);
|
|
const inMemoryJobs = process.env.WAKEUP_DRIVER === "memory" ? new InMemoryJobQueue() : undefined;
|
|
const jobs: JobPublisher = inMemoryJobs ?? new GraphileJobPublisher(pool);
|
|
const jobHost: JobWorkerHost =
|
|
inMemoryJobs ??
|
|
new GraphileJobWorkerHost(pool, {
|
|
concurrency: parsePositiveInteger(process.env.GRAPHILE_WORKER_CONCURRENCY, 4),
|
|
});
|
|
// One provider instance so emulator launches and polls share the same Map.
|
|
const cloudAgent = createCloudAgentConnection();
|
|
const media = createMediaRuntime({
|
|
prisma,
|
|
events,
|
|
jobs,
|
|
artifacts,
|
|
registrations: createMediaOperations({
|
|
loadSettings: () => loadMediaSettings({ prisma, secrets }),
|
|
}),
|
|
});
|
|
const executor = createRunExecutor({
|
|
prisma,
|
|
runtime,
|
|
sandbox,
|
|
memory: new MarkdownMemoryStore(prisma),
|
|
memoryProviders,
|
|
home,
|
|
artifacts,
|
|
connector: stack.connector,
|
|
connectors: stack.connector,
|
|
listConnectedPluginSlugs: async (userId) => {
|
|
const provider = await integrationSettings.resolve("composio");
|
|
if (!provider) return [];
|
|
return provider.listConnectedExternalIds({
|
|
userId,
|
|
spaceId: "",
|
|
operationId: "connections.sync",
|
|
traceId: "connections.sync",
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
},
|
|
secrets: [
|
|
deploymentModelKey ?? "",
|
|
process.env.COMPOSIO_API_KEY ?? "",
|
|
process.env.CURSOR_API_KEY ?? "",
|
|
].filter(Boolean),
|
|
secretStore: secrets,
|
|
deploymentModelKey,
|
|
dataDir,
|
|
notifications: new ExpoPushProvider(dataDir),
|
|
jobs,
|
|
events,
|
|
messaging: messaging ? createMessagingContextLoader(prisma) : undefined,
|
|
web: createWebProvider(),
|
|
cloudAgent,
|
|
media,
|
|
});
|
|
|
|
const jobHandlers = createBackgroundJobHandlers({
|
|
executor,
|
|
prisma,
|
|
sandbox,
|
|
home,
|
|
jobs,
|
|
events,
|
|
workerId: process.pid.toString(),
|
|
runtime,
|
|
secretStore: secrets,
|
|
memoryProviders,
|
|
deploymentModelKey,
|
|
messaging,
|
|
cloudAgent,
|
|
media,
|
|
});
|
|
// graphile-worker run() connects through the shared pool. createPool already
|
|
// retries connect() on 53300 a finite number of times. Keep retrying start
|
|
// until Postgres has capacity: exhausting then returning from main().catch
|
|
// left a live process that held connections but never ran jobs or registered
|
|
// signal handlers, even after capacity returned. Do not exit(1) here; that
|
|
// crash-loops into the same saturated Postgres. GraphileJobWorkerHost also
|
|
// observes runner.promise after start and restarts with the same backoff if
|
|
// the runner dies later on 53300 (unhandledRejection still swallows that
|
|
// code so we do not Docker crash-loop on transient completeJob failures).
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
await jobHost.start(jobHandlers);
|
|
break;
|
|
} catch (error) {
|
|
if (!isTooManyDatabaseConnections(error)) throw error;
|
|
logger.error("worker job host start waiting on database capacity", error);
|
|
await new Promise((resolve) => setTimeout(resolve, databaseCapacityBackoffMs(attempt)));
|
|
}
|
|
}
|
|
const reconciler = createJobReconciler({
|
|
prisma,
|
|
jobs,
|
|
events,
|
|
leadership: createPostgresReconciliationLeadership(pool),
|
|
reconcileCloudAgents: () => reconcileCloudAgents({ prisma, jobs, cloudAgent }),
|
|
reconcileComputerUpdates: () => reconcileComputerUpdates({ prisma, jobs }),
|
|
reconcileProfileConsolidation: createProfileConsolidationScheduler({ prisma, jobs }),
|
|
reconcileDelegatedTasks: () => reconcileDelegatedTasks({ prisma, jobs, events }),
|
|
reconcileMediaJobs: () => media.reconcile(),
|
|
});
|
|
reconciler.start();
|
|
|
|
let stopping = false;
|
|
const stop = async () => {
|
|
if (stopping) return;
|
|
stopping = true;
|
|
try {
|
|
await reconciler.stop();
|
|
await jobHost.stop();
|
|
await jobs.close();
|
|
await realtime.close();
|
|
await connector.stop();
|
|
await mcp.close();
|
|
await prisma.$disconnect().catch(() => undefined);
|
|
await pool.end().catch(() => undefined);
|
|
} finally {
|
|
await logger.flush({ timeoutMs: 2_000 });
|
|
}
|
|
};
|
|
process.once("SIGTERM", () => void stop());
|
|
process.once("SIGINT", () => void stop());
|
|
// graphile-worker fires completeJob() without awaiting it. When pool.connect()
|
|
// then hits Postgres 53300, that rejection is unhandled. Exiting here is the
|
|
// crash loop: Docker restarts the process before Postgres has reaped the old
|
|
// backends, so the next boot cannot connect either. Stay up on that rejection
|
|
// only — do not resume after uncaughtException (Node leaves the process in an
|
|
// undefined state).
|
|
process.on("uncaughtException", (error) => {
|
|
logger.error("uncaughtException", error);
|
|
void stop().finally(() => process.exit(1));
|
|
});
|
|
process.on("unhandledRejection", (reason) => {
|
|
logger.error("unhandledRejection", reason);
|
|
if (isTooManyDatabaseConnections(reason)) return;
|
|
void stop().finally(() => process.exit(1));
|
|
});
|
|
|
|
logger.info("worker ready");
|
|
}
|
|
|
|
main().catch(async (error) => {
|
|
logger.error("worker startup failed", error);
|
|
await logger.flush({ timeoutMs: 2_000 });
|
|
// jobHost.start retries 53300 without bound above, so a saturated Postgres at
|
|
// that step does not reach here. Other startup failures still exit.
|
|
process.exit(1);
|
|
});
|
|
// reload
|