Skip to main content

@happyvertical/smrt-jobs

Durable forge projections​

ForgeDeliveryCollection and ForgeProjectionRuntime provide a provider-neutral inbox for durable forge webhook projection. The inbox identity is (tenantId, provider, deliveryId), so provider retries are atomically deduplicated. A worker lease owns each attempt; failures use bounded exponential retry, then remain in dead_letter until an operator calls replay() from the owning tenant context.

leaseMs must be a finite positive duration. retryBaseMs and retryMaxMs must be finite, non-negative durations no greater than 2,147,483,647 ms; invalid timing configuration is rejected before a lease can be claimed. A lease that would exceed the JavaScript Date range is rejected before any lease write; a retry that would exceed that range is durably dead-lettered instead of being left leased.

const inbox = await ForgeDeliveryCollection.create({ db });

await withTenant({ tenantId }, () =>
inbox.accept({
provider: 'github',
deliveryId: request.headers.get('x-github-delivery')!,
eventName: 'pull_request',
repositoryKey: 'example/forge-repository',
payload,
}),
);

const runtime = new ForgeProjectionRuntime({ db, workerId: workerKey });
await runtime.processNext({
async observe(delivery) {
return {
projection: 'pull-request-revision',
subjectKey: `${delivery.repositoryKey}:pr:${delivery.payload.number}`,
version: Number(delivery.payload.revision),
value: delivery.payload,
};
},
async project(observation, context) {
// Always use context.db: it is the transaction shared by the application
// projection, monotonic checkpoint, and inbox completion.
await writeProjection(context.db, observation);
},
});

projection and subjectKey are application-defined. A pull request is not treated as a tracker issue. version must be a non-negative monotonic signed 32-bit integer (0 through 2,147,483,647); observations at or below the durable checkpoint are acknowledged without reapplying side effects. Provider normalization runs with the delivery's tenant context restored. Generated REST/MCP/CLI surfaces are disabled for both system tables; operator replay requires an explicit in-process tenant context.

The manifest-driven migration adds _smrt_forge_deliveries and _smrt_forge_projection_checkpoints. Run smrt db:migrate before starting a forge projection worker.

Background job execution for s-m-r-t objects. Provides persistent queue storage, retry strategies, cron-based scheduling, and a fluent JobBuilder API via the withBackgroundJobs() mixin.

Installation​

pnpm add @happyvertical/smrt-jobs

Usage​

Add background capabilities to a SmrtObject​

import { withBackgroundJobs, TaskRunner } from '@happyvertical/smrt-jobs';
import { Document } from './document.js';

// Mixin adds .bg() and .background() to any SmrtObject class
const BackgroundDocument = withBackgroundJobs(Document);
const doc = new BackgroundDocument({ db });
await doc.initialize();

// Quick enqueue — runs immediately when a TaskRunner picks it up
const handle = await doc.bg('generateSummary', { format: 'md' });

// Fluent builder for advanced options
const handle2 = await doc.background('generateSummary', { format: 'md' })
.delay('5m')
.priority('high')
.retries(5)
.queue('analysis')
.timeout(600000)
.enqueue();

// Wait for result (polling-based)
const result = await handle2.wait({ timeout: 60000, pollInterval: 100 });

Run a TaskRunner to process jobs​

import { TaskRunner } from '@happyvertical/smrt-jobs';

const runner = new TaskRunner({
concurrency: 5,
pollInterval: 1000,
// Empty checks back off exponentially to this cap (20× by default).
idlePollInterval: 20000,
queues: ['default', 'analysis'],
});
await runner.initialize(db);
await runner.start();

// Listen for events
runner.on('job:completed', (job, result) => { /* ... */ });
runner.on('job:failed', (job, error) => { /* ... */ });

// Graceful shutdown
process.on('SIGTERM', () => runner.stop());

Polling delays are capped at the effective worker lease TTL, including a larger pollInterval, to bound orphan-recovery sweep spacing. Both polling intervals must be finite positive milliseconds within the Node timer range. The default 20× idle cap reduces combined claim and recovery traffic; latency-sensitive workers can set a smaller cap.

Back MCP task operations with durable jobs​

McpTaskStore persists the MCP io.modelcontextprotocol/tasks lifecycle on the same _smrt_jobs row that executes the operation. createTask() creates a correlated job, getTask() maps its queue state, and cancelTask() cancels that exact job without leaving a second record behind. Long-running task actions can request client input through JobExecutionContext.task:

async generate(options: Record<string, never>, context: JobExecutionContext) {
const { tone } = await context.task!.requestInput({ tone: { type: 'string' } });
return this.render(tone);
}

Run a TaskRunner for the mcp-tasks queue in application deployments. Task cancellation is cooperative: the job row becomes cancelled immediately and a running handler must observe context.signal (an AbortSignal that aborts on cancellation, or on a 'fail'/'kill' timeout) before doing further side effects.

Liveness-safe job execution​

TaskRunner records heartbeat telemetry, but recovery keys on a worker incarnation's live lease rather than a per-job heartbeat threshold. A blocked event loop must not make a still-running handler appear dead and cause a concurrent duplicate execution. See the live-set and off-loop lease-renewal details in Worker liveness & recovery.

Job handlers remain at-least-once. Avoid synchronous, CPU-bound, or otherwise long-running work when possible; make external effects idempotent because a process crash after an effect but before its terminal write still permits a later retry.

Read safe terminal outcomes​

Completion, permanent failure, timeout, stale-worker recovery, and cancellation persist the job's terminal state and a versioned _smrt_job_events outcome in one database transaction. A retry remains nonterminal and is not projected. Ordinary progress and log events remain best-effort telemetry. The event uses the row returned by the conditional update, so a concurrent claim cannot leave a stale attempt count in history. Public cancellation retains the normal ambient-tenant check; explicit system context remains the operator bypass.

const events = await SmrtJobEventCollection.create({ db });
const page = await events.listTerminalOutcomes({
tenantId,
queues: ['reports'],
objectTypes: ['SmrtDataSurfaceActionTask'],
methods: ['run'],
limit: 100,
});

The explicit tenant boundary is required; pass tenantId: null only for global jobs. Results are newest-first and expose job ID, terminal status, queue, object type, method, attempt count, completion time, cursor, and the bounded failure class execution, timeout, or stale-recovery. They never expose arguments, object IDs, results, raw errors, or stacks. Reads inspect at most 1,000 candidates per page and follow job-event retention (30 days by default). Applications must still authorize each job ID against their own durable ownership record before displaying it.

Schedule recurring jobs with ScheduleRunner​

The ScheduleRunner polls the _smrt_agent_schedules table for due cron entries and creates SmrtJob records for the TaskRunner to execute. Each schedule's cron is evaluated in its row's IANA timezone (default UTC, DST-correct), independent of the host TZ. Each comma-separated list term is matched separately, and a step (*/n, a-b/n, a/n) counts from the field minimum or the term's base, as in standard cron.

Upgrade note: rows that relied on the old behaviour shift. A row left at the default timezone = 'UTC' on a non-UTC host now fires in UTC, and day-of-month or month */n now matches 1, 1+n, … (for example 0 0 */2 * * runs on odd days) instead of counting from 0.

Wire them together via events:

import { ScheduleRunner } from '@happyvertical/smrt-jobs';

const scheduleRunner = new ScheduleRunner({ pollInterval: 30000 });
await scheduleRunner.initialize(db);
await scheduleRunner.start();

// Connect TaskRunner events to update schedule state
taskRunner.on('job:completed', (job) => {
const scheduleId = job.args?._scheduleId;
if (scheduleId) scheduleRunner.handleJobCompletion(scheduleId, true);
});
taskRunner.on('job:failed', (job, error) => {
const scheduleId = job.args?._scheduleId;
if (scheduleId) scheduleRunner.handleJobCompletion(scheduleId, false, error.message);
});

System Tables​

TablePurpose
_smrt_jobsPersistent job queue (SmrtJob records)
_smrt_agent_schedulesCron schedule entries polled by ScheduleRunner

API​

Classes​

ExportDescription
SmrtJobPersistent job record stored in _smrt_jobs
SmrtJobCollectionCollection with claimReady(), listReady(), listByStatus(), stats(), cleanup()
SmrtJobEventCollectionDurable telemetry plus explicit-tenant listTerminalOutcomes() safe projection
JobBuilderFluent API: .delay(), .priority(), .retries(), .queue(), .timeout(), .enqueue()
JobHandleTrack, wait, cancel, or retry an enqueued job
JobContextLoggerLogger that auto-injects job context (jobId, attempt, queue)
TaskRunnerPolling-based execution engine with concurrency control and liveness leases
ScheduleRunnerPolls for due cron schedules and creates SmrtJob entries

TaskRunner uses SmrtJobCollection.claimReady() so multiple workers can poll the same queue without duplicate-claiming a pending row.

Functions​

ExportDescription
createTaskRunner(config?)Factory for creating a configured TaskRunner
createScheduleRunner(config?)Factory for creating a configured ScheduleRunner
withBackgroundJobs(Class)Mixin that adds .bg() and .background() to any SmrtObject class
parseDelay(delay)Parse human-readable delay strings ('5m', '1h', '30s') to milliseconds
priorityToNumber(priority)Convert priority label ('critical'/'high'/'normal'/'low') to number
createHmacDurableJobPayloadSigner({ keyId, key })Sign canonical JSON job payloads with a server-only key and verify them after persistence

Key Types​

Priority, JobStatus, JobResult, WaitOptions, BgOptions, BackgroundCapable, TaskRunnerConfig, TaskRunnerEvents, ScheduleRunnerConfig, ScheduleRunnerEvents, ScheduleInfo, JobContext, TimeoutBehavior, SmrtJobData, ListReadyOptions, DurableJobPayloadSigner, DurableJobPayloadIntegrity

Dependencies​

  • @happyvertical/smrt-core -- ORM and code generation
  • @happyvertical/smrt-config -- configuration loading
  • @happyvertical/smrt-types -- shared type definitions
  • @happyvertical/jobs -- retry strategies
  • @happyvertical/sql -- database interface
  • @happyvertical/logger -- structured logging
  • @happyvertical/utils -- ID generation utilities
  • Peer (optional): @happyvertical/smrt-svelte, svelte

Restartable MCP input​

For a workflow that must survive process restart, create the task with continuation: { recordId, revision, inputKey }. These are immutable references to an existing application review/action record, not approval or authority. Create the store with both ownerId and tenantId; task creation must match that active tenant. The application adapter must derive these values from its verified principal, never tool arguments.

Configure TaskRunner.authorizeMcpTask to resolve live grants and validate the bound actor/tenant against the owning record. It is mandatory for continuation tasks and for every remote app-created task, and fails closed if absent or unavailable. Provider exceptions become the fixed MCP task authorization denied error before task persistence; internal provider details are never exposed through task status. It runs before each invocation; context.task.assertAuthorized() repeats it and checks worker ownership before apply. A permission snapshot must not be persisted in the job.

context.task.requestContinuation(binding, inputDescriptor) stores one input round in the existing job row and suspends execution, releasing the worker. Waiting rows are not claimable until updateTask() atomically stores the first complete answer. A new worker starts the method from its beginning and retrieves the saved answer. Calls before this seam must be read-only or use the owning runOnce/action idempotency contract. The binding cannot change between rounds; use a new owning action/task for another review revision. JSON input is bounded to 64 KiB. Unknown response keys are ignored, incomplete responses do not wake the job, and repeated answers cannot overwrite the first accepted response. Once an answer is accepted, status is working while queued or resumed; the immutable response remains available for replay without asking for input again.

getContinuation(taskId) exposes the waiting descriptor only through the same actor/tenant-scoped store. Use it in an explicitly declared authorized workflow tool or application form. It adds no fields to MCP tasks/get. The stateless HTTP adapter retains no callback; absent elicitation, offer the application's review URL/form. OpenAI MRTR is an optional protocol adapter responsibility and is not implemented by this job API. The legacy requestInput() remains a running-handler wait bounded by the job timeout and is not restartable.

Continuation tasks and remote app-created tasks do not automatically retry errors or unknown external outcomes. Reconcile those through the owning action state's durable reservation/evidence API; never blindly resubmit. Cancellation guards subsequent cooperative work and terminal writes, but cannot retract an external side effect already sent. The owning apply transaction must revalidate immutable review/revision and idempotency. A host answer never creates final human approval; Iolaus continues to use its dedicated human review page.

Persistence checks: pnpm --filter @happyvertical/smrt-jobs test and pnpm --filter @happyvertical/smrt-jobs test:postgres. The continuation suite also runs against a managed PostgreSQL database with:

node scripts/run-with-ci-postgres.mjs -- pnpm --filter @happyvertical/smrt-jobs test src/__tests__/mcp-continuation.optional.test.ts

Supply CI_POSTGRES_BASE_URL for a disposable test server; the wrapper creates and drops a synthetic database. Do not point destructive fixtures at user data.

Generated stdio servers retain their existing trusted-local-process assumption: SMRT_MCP_TENANT_ID is a deployment scope and existing task owner namespace, not an authenticated human identity. The generated store now also passes the same active tenant used by task creation. Multi-user remote workflows require the verified application principal path and a live runner authorizer.

The app integration exports continueMcpWorkflow() and createMcpContinuationTool() from @happyvertical/smrt-app-mcp. The first composes this job seam with an application's applyReviewed callback; that callback must invoke its owning immutable approval and idempotency API, not a raw mutation. The second declares a read-only workflow tool for a host without elicitation. Register that tool through the app server's existing workflowTools option. Supply a storeFor(verifiedPrincipal) bound to the same owner namespace and tenant used for task creation, and a live authorize callback. It returns text and structured form/review URL data; no iframe, subscription, or host-specific form implementation is required. Review URLs must use HTTPS, or HTTP on loopback for a local deployment, without credentials.

Remote createMcpAppServer task stores always set requireAuthorization: true. This server-owned requirement is persisted separately from user invocation arguments; tool arguments cannot disable it. An ordinary remote task without continuation metadata must therefore configure the same live worker callback. The legacy input seam also checks current authority before waiting and after input returns. Applications must call context.task.assertAuthorized() again immediately before later side effects. Direct McpTaskStore callers default to trusted-local compatibility; set requireAuthorization: true for any remote or multi-user application. This default does not confer human identity.

Shutdown includes any in-flight claim in the existing bounded drain and keeps the worker lease until that drain finishes. A claim returning after shutdown started is released without dispatch or consuming an attempt, even if the shutdown deadline has elapsed; the next incarnation may claim it normally. The same runner cannot restart while an earlier poll or handler is still draining.

Durable continuation ready-index rollout​

_smrt_jobs_ready_idx is an additive declared nonunique index on (status, run_at), restricted to status = 'pending' AND (task_input_requests IS NULL OR task_input_responses IS NOT NULL). Both listReady() and claimReady() use this exact eligibility predicate. Suspended already-due jobs remain pending, but SQLite/PostgreSQL exclude them from this index until an answer wakes them. The original _smrt_jobs_status_run_at_idx remains for other status/time reads.

Deploy through the owning schema migration path: run smrt db:migrate before running the updated workers; for continuously written PostgreSQL queues use smrt db:migrate --postgres-safe so the index builds concurrently. No runtime task/runner API creates the index. The old schema remains functionally correct until migration, but polling can scan the suspended backlog. This changes no column or task status. DuckDB/JSON use the existing DDL strategy's full-index fallback and do not gain suspended-backlog exclusion; the readiness filter still preserves correctness on those engines.

Task creation snapshots only validated recordId, revision, and inputKey values before any awaited enqueue work. Extra caller fields are not persisted, and later mutation of the caller's binding cannot change the durable review reference. Live authorization still checks the saved reference at execution and resume; the snapshot never grants approval.