Skip to main content

Architecture: Job-Runtime Providers (pluggable background-job runtime)

Status: In progress — EW-685 P0 contract shipped (no behaviour change). EW-686 P1 (rehouse Trigger.dev as the trigger plugin) + binding factory + per-provider plugins remain. Last updated: 2026-06-18 Audience: AI agents and engineers who need to understand how the platform's long-running work is dispatched, scheduled, cancelled, and executed — and how that runtime becomes a swappable provider (Trigger.dev default; Temporal / BullMQ / pg-boss / Inngest optional).

This spec describes the target architecture. Nothing here is built yet. It generalises the existing trigger-integration and trigger-worker specs into a provider-neutral form. Read those two first — this spec assumes them. Decision rationale: ADR-015. Per-provider detail: features/job-runtime-providers/providers.md.


1. Purpose​

Long-running operations (work generation, import, onboarding, scheduled dispatch, KB embedding, webhook delivery, agent heartbeats, deploy-ready polling, mission ticks) cannot run inside an HTTP request. Today they all run on Trigger.dev SaaS. This spec defines the job-runtime capability: a provider contract that lets a deployment choose which runtime executes that work, while the rest of the platform — the API, the agent business logic, the dispatcher call sites — stays identical regardless of choice.

The runtime owns exactly six concerns, and only these six:

  1. Enqueue — accept a typed payload and durably hand it to a worker.
  2. Schedule — fire recurring/cron work (e.g. the schedule dispatcher every N minutes).
  3. Cancel — abort an in-flight run by id.
  4. Status — report run lifecycle (queued → running → completed/failed/cancelled) back so the API can mirror it onto WorkGenerationHistory etc.
  5. Retry / idempotency — re-run safely with stable identity.
  6. Worker hosting — run the process that executes @ever-works/agent orchestrators.

Everything else — what the work does, the SuperJSON callback channel, the pre-created historyId, secret hygiene — is provider-neutral and unchanged.

2. The seam that makes this possible​

The platform already isolates the runtime behind dispatcher interfaces owned by the agent package. This is the entire reason a swap is tractable. Each is a tiny interface plus a DI Symbol:

// packages/agent/src/tasks/work-generation-dispatcher.ts (exists today)
export interface WorkGenerationDispatcher {
dispatchWorkGeneration(payload: WorkGenerationPayload): Promise<string | null>;
cancelWorkGeneration(runId: string): Promise<boolean>;
}
export const WORK_GENERATION_DISPATCHER = Symbol('WORK_GENERATION_DISPATCHER');

The API depends only on these symbols; it has no @trigger.dev/sdk import. TriggerService (packages/tasks/src/trigger/trigger.service.ts) implements all of them today. The eight dispatcher seams in use:

Dispatcher symbolPayloadUsed by
WORK_GENERATION_DISPATCHERWorkGenerationPayloadWorkGenerationService
WORK_IMPORT_DISPATCHERWorkImportPayloadWorkImportService
TEMPLATE_CUSTOMIZATION_DISPATCHERTemplateCustomizationPayloadtemplate customization
WEBHOOK_DELIVERY_DISPATCHERWebhookDeliveryPayloadwebhook delivery
KB_MIRROR_DOCUMENT_DISPATCHERKbMirrorDocumentPayloadKB mirror
KB_BACKFILL_SKELETON_DISPATCHERKbBackfillSkeletonPayloadKB backfill
KB_EMBED_DOCUMENT_DISPATCHERKbEmbedDocumentPayloadKB embed
KB_ORG_OVERLAY_FANOUT_DISPATCHERKbOrgOverlayFanoutPayloadKB org overlay
(+ AGENT_TASK_EXECUTE_DISPATCHER, AGENT_CHAT_REPLY_DISPATCHER wired in apps/api/src/tasks/tasks.module.ts)agents

The refactor's premise: instead of binding these symbols to TriggerService, bind them to whichever IJobRuntimeProvider is active. Call sites do not change.

3. The IJobRuntimeProvider contract​

A new capability job-runtime is registered in packages/plugin/src/contracts/capabilities/. A provider plugin extends BasePlugin (category job-runtime, configurationMode: 'admin-only') and exposes:

// packages/plugin/src/contracts/capabilities/job-runtime.interface.ts (shipped in EW-685 P0)
export interface IJobRuntimeProvider {
/** Stable provider id: 'trigger' | 'temporal' | 'bullmq' | 'pgboss' | 'inngest' */
readonly runtimeId: JobRuntimeId;

/** One object that implements every agent dispatcher interface (enqueue + cancel). */
readonly dispatchers: JobRuntimeDispatchers;

/** Register/lifecycle the recurring jobs the platform needs (cron). */
registerSchedules(schedules: ScheduleSpec[]): Promise<void>;

/** Cancel an in-flight run by the id returned at enqueue time. */
cancel(runId: string): Promise<boolean>;

/** Look up live run status (used where webhooks/callbacks aren't available). */
getRunStatus(runId: string): Promise<JobRunStatus>;

/** True when this provider is configured & reachable in the current env. */
isEnabled(): boolean;

/** Optional: stand up / connect the worker host (see §5). */
startWorkerHost?(opts: WorkerHostOptions): Promise<WorkerHostHandle>;

/**
* EW-686 P2 / EW-742 P3 — optional. Return a provider instance bound
* to the given tenant's credential snapshot (BYO/override mode).
* Returns `undefined` if the provider doesn't support BYO; the
* resolver falls back to the instance default with a `Logger.warn`.
* Implementations MUST memoise behind `credentialVersion` so repeat
* calls with the same snapshot return equivalent providers.
*/
bindToTenant?(snapshot: TenantCredentialSnapshot): IJobRuntimeProvider | undefined;
}

export interface TenantCredentialSnapshot {
tenantId: string;
providerId: JobRuntimeId;
credentialVersion: number;
credentials: Readonly<Record<string, unknown>>; // opaque, per-provider
}

export type JobRunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' | 'unknown';

export interface ScheduleSpec {
id: string; // e.g. 'work-schedule-dispatcher'
cron: string; // standard 5-field cron
payload?: unknown; // optional static payload
}

export interface JobEnqueueOptions {
tags?: string[];
idempotencyKey?: string; // stable identity across retries
concurrencyKey?: string; // serialise per key (e.g. per workId/orgId)
maxDurationSeconds?: number;
machineHint?: string; // provider maps to its own sizing
}

JobRuntimeDispatchers is the union of the existing dispatcher interfaces (WorkGenerationDispatcher & WorkImportDispatcher & … ). The contract deliberately keeps the dispatch signature identical to today (Promise<string | null> — run id or "runtime disabled, fall back in-process").

Design constraints the contract must hold​

  • string | null enqueue return is preserved. A disabled/unreachable runtime returns null; the API's existing in-process dev fallback is the provider-neutral safety net.
  • Idempotency is first-class. The pre-created historyId already gives every dispatch a stable identity. Providers MUST map idempotencyKey onto their native mechanism (Trigger.dev idempotencyKey, Temporal workflow id, BullMQ/pg-boss job id, Inngest event idempotency) so retries write to the same WorkGenerationHistory row.
  • Concurrency keys are preserved. Today some tasks are concurrency-keyed (KB mirror/embed on workId, org overlay on organizationId). The contract carries concurrencyKey; each provider maps it (Trigger.dev queue.concurrencyKey, BullMQ groups / job ids, Temporal workflow id namespacing, pg-boss singleton keys).
  • Cancellation must propagate an AbortSignal into the orchestrator so in-flight AI/search/git calls abort — exactly as the Trigger.dev onCancel/signal path does today (see trigger-integration §8).

4. Selection: one active runtime per deployment, env-driven​

Unlike per-user/per-work plugins (AI, search, deployment), the job runtime is deployment infrastructure — there is exactly one active runtime per deployment, chosen by the operator, scoped global/admin. This matches the k8s deployment plugin's admin-only config and ADR-005's cache/lock backend selection.

EVER_WORKS_JOB_RUNTIME=trigger # trigger (default) | temporal | bullmq | pgboss | inngest

Binding (proposed) lives where the dispatcher symbols are provided. A factory reads EVER_WORKS_JOB_RUNTIME, resolves the matching registered job-runtime plugin from PluginRegistryService, and binds its .dispatchers to all *_DISPATCHER symbols:

// packages/agent/src/tasks/job-runtime.providers.ts (proposed shape)
const provider = jobRuntimeRegistry.getActive(); // by EVER_WORKS_JOB_RUNTIME
[
WORK_GENERATION_DISPATCHER,
WORK_IMPORT_DISPATCHER,
TEMPLATE_CUSTOMIZATION_DISPATCHER,
WEBHOOK_DELIVERY_DISPATCHER,
KB_MIRROR_DOCUMENT_DISPATCHER,
KB_BACKFILL_SKELETON_DISPATCHER,
KB_EMBED_DOCUMENT_DISPATCHER,
KB_ORG_OVERLAY_FANOUT_DISPATCHER
].forEach((sym) => bind(sym, () => provider.dispatchers));

Provider-specific credentials/options use the standard plugin settings system (x-secret, x-envVar, JSON-Schema, admin scope). The selector (EVER_WORKS_JOB_RUNTIME) is the only "which provider" knob; everything else is the chosen provider's settings.

┌──────────────────────────────────────────────────────────────┐
│ apps/api — dispatch call sites depend ONLY on *_DISPATCHER │
│ symbols (provider-neutral, unchanged) │
└───────────────┬────────────────────────────────────────────────┘
│ EVER_WORKS_JOB_RUNTIME selects ONE provider
┌───────┴────────┬─────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
trigger (default) temporal bullmq pgboss inngest
SaaS / self-host self/Cloud Redis Postgres SaaS only
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────────────────────────────────────────────────────────┐
│ Worker host runs @ever-works/agent orchestrators │
│ (same SuperJSON callback channel to /internal/* for DB) │
└────────────────────────────────────────────────────────────┘

5. Worker-hosting models (the hard part)​

Enqueue is the easy half. The genuinely different part is how each runtime hosts and invokes the worker that runs the agent orchestrators. The contract abstracts dispatch; it cannot fully abstract hosting, so each provider documents its model and ships its own worker entrypoint + deploy artifacts. The agent orchestrator code (TriggerGenerationOrchestrator → generalise to JobOrchestrator) and the SuperJSON callback channel are reused unchanged.

ProviderWorker-hosting modelWho invokes the workerCron mechanism
Trigger.devTasks deployed to Trigger.dev (pnpm deploy:trigger); Trigger.dev runs them on its machines (cloud or self-hosted).Trigger.dev platformschedules.task
TemporalLong-running Worker process polls a task queue; we run it as a deployment/sidecar. Orchestrator = a Temporal Workflow; agent work = Activities.Temporal Service hands tasks to polling workersTemporal Schedules API
BullMQIn-process or sidecar Worker consuming a Redis queue; we run N worker replicas.The worker process pulls jobs from RedisBullMQ repeatable jobs (JobScheduler)
pg-bossIn-process or sidecar pg-boss instance polling Postgres; boss.work(name, handler).pg-boss polling Postgrespg-boss schedule() (cron)
InngestFunctions served over HTTP (serve() handler mounted in the API or a small service); Inngest Cloud invokes them via webhook.Inngest Cloud calls our HTTP endpointInngest cron functions

Key implications captured for the plan:

  • Trigger.dev and Inngest are "push" (the platform invokes our code); Temporal/BullMQ/pg-boss are "pull" (our worker polls). The contract's optional startWorkerHost() exists for the pull model (Temporal/BullMQ/pg-boss start a long-lived worker); push providers (Trigger.dev, Inngest) implement it as a no-op or as the HTTP serve() mount.
  • The agent orchestrator must be runtime-agnostic. Today it's withWorkerContext() + a NestJS application context bootstrap. That bootstrap is reused by every pull-model worker; the push-model providers wrap the same bootstrap inside their task/function handler.
  • Cancellation differs sharply. Trigger.dev/Temporal have first-class cancel with signal propagation; BullMQ/pg-boss need a cooperative cancel (a cancellation flag the orchestrator polls at each throwIfGenerationCancelled checkpoint — the checkpoints already exist). Inngest cancels via its cancelOn/event model. The contract's cancel(runId) hides this; each provider implements it natively.
  • maxDuration (5h generation). Trigger.dev supports it directly. Temporal supports arbitrarily long workflows. BullMQ/pg-boss have no hard cap but need lock-renewal/visibility-timeout tuning for multi-hour jobs (documented per provider). Inngest steps have per-step limits — long pipelines must be expressed as multiple steps.

6. Status reporting back to the API​

Today the worker writes terminal state itself (orchestrator + onFailure/onCancel) and the API mirrors run ids. The contract keeps this: the orchestrator remains the source of truth for WorkGenerationHistory status, so most status flows are provider-neutral. getRunStatus(runId) is only needed where a provider lacks worker-side terminal writes or where the API polls (e.g. a future UI "live runtime status" panel). Providers that support webhooks (Trigger.dev, Inngest) may push status; pull providers expose getRunStatus by querying their store (BullMQ Job.getState(), pg-boss getJobById, Temporal DescribeWorkflowExecution).

7. Conformance suite (parity guarantee)​

A provider-agnostic contract test in packages/plugin/src/contracts/__tests__/ (type-level shape spec in job-runtime.spec.ts shipped EW-685 P0; runtime conformance job-runtime.conformance.spec.ts lands with the first concrete provider) exercises every provider identically (mirrors ADR-005's LockProvider contract suite):

  • enqueue returns an id; the worker runs and writes terminal state
  • idempotency: same idempotencyKey → one logical run, retries reuse the history row
  • concurrency: same concurrencyKey serialises
  • cancel: in-flight run aborts; orchestrator observes the signal/flag and writes CANCELLED
  • schedule: a registered cron fires on cadence
  • disabled/unreachable runtime: enqueue returns null, API falls back in-process (dev)

A provider is not "supported" until green on this suite. Until then it ships behind an experimental flag.

8. Configuration contract​

Selector + per-provider settings (full matrix in providers.md). Illustrative:

# Selector (default trigger — existing deployments need nothing)
EVER_WORKS_JOB_RUNTIME=trigger

# trigger (unchanged — see trigger-integration.md §12)
TRIGGER_SECRET_KEY=... TRIGGER_API_URL=https://api.trigger.dev
TRIGGER_INTERNAL_SECRET=... TRIGGER_MACHINE=medium-1x

# temporal
TEMPORAL_ADDRESS=temporal.internal:7233 TEMPORAL_NAMESPACE=ever-works
TEMPORAL_TASK_QUEUE=ever-works-jobs
TEMPORAL_TLS_CERT / TEMPORAL_TLS_KEY # for Temporal Cloud (mTLS)

# bullmq (Redis-only)
BULLMQ_REDIS_URL=redis://... BULLMQ_PREFIX=ew BULLMQ_CONCURRENCY=5

# pgboss (Postgres-native — can reuse the platform DB)
PGBOSS_DATABASE_URL=postgres://... (defaults to platform DATABASE_URL)
PGBOSS_SCHEMA=pgboss

# inngest (SaaS only)
INNGEST_EVENT_KEY=... INNGEST_SIGNING_KEY=... INNGEST_APP_ID=ever-works

The internal SuperJSON callback channel (TRIGGER_INTERNAL_SECRET, internal base URL) is provider-neutral — every pull-model worker still calls back to /internal/* over the same authenticated RPC. The env names may be generalised (EVER_WORKS_INTERNAL_SECRET) with the TRIGGER_* names kept as aliases for back-compat.

9. Coexistence & migration​

  • Default path is byte-for-byte unchanged. No EVER_WORKS_JOB_RUNTIME → trigger. The trigger provider IS the current TriggerService, re-housed.
  • Switching runtimes is a deploy-time action, not a live migration. In-flight runs drain on the old runtime (or are re-enqueued idempotently). Documented in the per-provider deploy guide.
  • Self-hosted Trigger.dev (EW-592) = trigger provider with TRIGGER_API_URL pointed at the self-hosted instance. No new provider needed for that case.

10. File index​

packages/plugin/src/contracts/capabilities/
└── job-runtime.interface.ts # IJobRuntimeProvider, JobRunStatus, ScheduleSpec, options (shipped EW-685 P0)

packages/plugin/src/contracts/__tests__/
├── job-runtime.spec.ts # type-level shape assertions (shipped EW-685 P0)
└── job-runtime.conformance.spec.ts # runtime conformance suite (lands with first provider, EW-686 P1)

packages/agent/src/tasks/
├── *.dispatcher.ts # EXISTING dispatcher interfaces (unchanged)
└── job-runtime.providers.ts # NEW factory: EVER_WORKS_JOB_RUNTIME → bind symbols (EW-685 P0 seam half, lands with EW-686 P1)

packages/plugins/
├── job-runtime-trigger/ # re-housed TriggerService (default)
├── job-runtime-temporal/ # @temporalio/* worker + workflows + activities
├── job-runtime-bullmq/ # bullmq Queue + Worker + JobScheduler
├── job-runtime-pgboss/ # pg-boss boss.work + schedule
└── job-runtime-inngest/ # inngest serve() + functions (SaaS)

packages/tasks/ # remains the trigger provider's worker package
# (other pull providers get sibling worker entrypoints)

11. Tenant-Scoped Overlay (multi-tenant extension)​

This document defines the instance-global runtime selection (EVER_WORKS_JOB_RUNTIME chooses one provider per deployment). For multi-tenant deployments — Ever Works Cloud and any operator hosting multiple tenants on one instance — that single selection is the fallback; each tenant can layer an overlay on top to inherit, BYO credentials for the same provider, or override to a different enabled provider. The overlay reuses the dispatcher seam from this doc unchanged — it plugs in via a TenantAwareRuntimeResolver placed in front of the EW-685 binding factory.

See the dedicated feature set for the overlay design:

12. See Also​