# Background jobs

**One typed handler file is the whole job.** Registration, dispatch, retry, execution tracking, scheduling, tracing, and the HTTP trigger API all come from the worker runtime — so shipping a working background job (whether you write it or an AI agent does) means authoring a single `defineJobHandler` module, not first assembling a queue, a worker pool, and an inspection UI around it.

A NetScript **background job** is a durable, KV-backed TypeScript handler that runs in its own thread-isolated worker, separate from your request-serving services. You author a job as one `defineJobHandler(...)` callable, give it an `id`, and the runtime takes care of registration, dispatch, retry, execution tracking, scheduling, and an HTTP API to enqueue and inspect runs. It is the unit you reach for whenever work should happen *after* a request returns — charging a payment, sending a welcome email, processing an upload — without blocking the caller.

![An enqueue call (from a trigger, an HTTP POST to the workers API, or the scheduler) places a job on the durable queue; the worker runtime pulls it and runs the handler in one of three runner modes — in-process, web-worker (one V8 isolate per worker), or subprocess — then writes a JobResult to the KV-backed execution store, which streams updates back over SSE.](https://rickylabs.github.io/netscript/netscript/assets/diagrams/queue-worker-scheduler.svg)

*Enqueue → durable queue → worker runtime (in-process / web-worker / subprocess) → result store. The scheduler fires cron-defined jobs onto the same queue; graceful shutdown drains in-flight runs before the runner stops.*

## The story: a screenshot becomes a diagnosis

The clearest picture of what this contract buys comes from a production incident-diagnosis assistant built on NetScript for a legacy ERP estate. A support engineer pastes an alert-email screenshot into a chat channel; a background job runs a vision model over the image and extracts the structured fields (program, error number, table, timestamp) that downstream tooling needs to trace the failure.

Two properties of the job contract shaped that worker into its correct form:

- **The payload is queue-borne, so it stays small.** The first version enqueued the raw image bytes and hit Deno KV's 64 KB enqueue limit. The fix — carry only a small reference through the queue and fetch the bytes back over a typed service client inside the handler — is exactly the shape the contract steers you toward: the queue moves *references to work*, not blobs.
- **Workers stay compute-only.** The app's channel database holds an exclusive OS file lock, so a second process opening it crashes outright. Because a job handler receives a `ctx` and returns a `JobResult` — rather than a database connection — writing results back through the owning service's typed client was the natural default, not a discipline the team had to invent. The single writer keeps owning its database; the worker never touches it.

The same run is traced end to end: dispatch, execution, progress events, and the subprocess hop (if any) land in the [Aspire dashboard](https://rickylabs.github.io/netscript/explanation/aspire/) without handler-side wiring. The rest of this page is the mechanism behind that story.

## What it is

A job is plain TypeScript that runs **in the worker process, not the request process**. The headline surface — `defineJobHandler`, `createSuccessResult`, and `createFailureResult` from `@netscript/plugin-workers-core` — lets you write a typed handler over a `ctx`, do the work, and return a structured `JobResult`. Job dispatch and execution are instrumented with real OpenTelemetry spans that show up in the [Aspire dashboard](https://rickylabs.github.io/netscript/explanation/aspire/) automatically, so a queued run is observable end to end without wiring. The runner mode (how the handler is isolated) is a **user-tunable** — see the runtime-mode table below — and the same queue and scheduler also drive [polyglot tasks](https://rickylabs.github.io/netscript/background-processing/polyglot-tasks/) when the work is owned by another runtime. The why-behind-the-choreography lives in [Durability model](https://rickylabs.github.io/netscript/explanation/durability-model/).

> Jobs vs. tasks vs. sagas vs. services
>
> Reach for a
>
> background job
>
> when the work is
>
> fire-and-forget or deferrable
>
> and written in TypeScript: it should survive the request that started it, run on its own schedule or trigger, and be retried and observed independently. If the work must run in
>
> another runtime
>
> (Python, .NET, a shell script, a binary), use a
>
> polyglot task
>
> — it shares this same queue, retry, and telemetry machinery but spawns a subprocess. If the work
>
> coordinates several steps across time
>
> (waiting for messages, compensating on failure), model it as a
>
> durable saga
>
> ; jobs and sagas compose. For a
>
> synchronous request/response API
>
> , author a
>
> service
>
> instead.

## Learn → / Do →

[Learn — ERP Sync, lesson 02  The tutorial rung: add the worker plugin to the running app, author an import job, and trigger it over :8091 as part of the ERP-sync narrative.](https://rickylabs.github.io/netscript/netscript/tutorials/erp-sync/02-import-job/)

[Do — Tune the worker runtime  Recipe: pick the in-process / web-worker / subprocess runner, set WORKERS_CONCURRENCY, and choose a queue provider for your deployment.](https://rickylabs.github.io/netscript/netscript/background-processing/how-to/tune-worker-runtime/)

[Understand — Polyglot tasks  Run non-TypeScript work (Python, .NET, shell) on the same durable queue and scheduler as a managed subprocess.](https://rickylabs.github.io/netscript/netscript/background-processing/polyglot-tasks/)

## Minimal example

Add the workers plugin to a published workspace with the public package install flow:

```bash
netscript plugin install @netscript/plugin-workers
```

> Historical: import-map patch no longer required
>
> Projects scaffolded with an early published CLI (
>
> --package-source jsr
>
> ) on
>
> 0.0.1-beta.7
>
> generated a root
>
> deno.json
>
> whose import map omitted the
>
> @netscript/sdk
>
> and
>
> @netscript/sdk/client
>
> entries, requiring a manual patch before the background worker could load jobs. The scaffold generator now emits both entries in JSR mode, so no manual fix is needed on current releases.

For local-source contributor work inside this monorepo, use the maintainer binary when you need first-party samples:

```bash
deno run -A packages/cli/bin/netscript-dev.ts plugin install worker --name workers --samples
```

That local path lands real, compiling modules you can read and trigger immediately — including `plugins/workers/jobs/health-check.ts` (a job handler) and `plugins/workers/tasks/validate-payload.ts` (a polyglot task). The plugin's API service comes up on **port 8091**.

A job handler is an async callable over a `ctx` object that returns a `JobResult`. Parse the payload with a Zod schema, do the work, and return `createSuccessResult(...)` or `createFailureResult(...)`. The job's identity is attached with `Object.assign(handler, { id })`.

```ts
// plugins/workers/jobs/process-payment.ts
import {
  createFailureResult,
  createSuccessResult,
  defineJobHandler,
} from '@netscript/plugin-workers-core';
import { z } from 'zod';

// Payload contract — parse ctx.payload before doing any work.
const ProcessPaymentPayloadSchema = z.object({
  orderId: z.string().min(1),
  amountCents: z.number().int().positive(),
});

const handler = defineJobHandler(async (ctx) => {
  const { orderId, amountCents } = ProcessPaymentPayloadSchema.parse(ctx.payload ?? {});

  // Optional progress callback — the runtime wires reportProgress on real runs.
  ctx.reportProgress?.(10, 'charging customer');

  const charge = await chargeCustomer(orderId, amountCents);
  if (!charge.ok) {
    // A failure result is recorded and feeds the retry policy.
    return createFailureResult(`charge declined: ${charge.reason}`);
  }

  ctx.reportProgress?.(100, 'charged');
  // The success payload (data) is persisted on the execution record.
  return createSuccessResult({ orderId, chargeId: charge.id, amountCents });
});

// The id is how the runtime registers, lists, and triggers the job.
export default Object.assign(handler, { id: 'process-payment' });
```

Once the workers API is up (Aspire first — see Production notes), enqueue a run by `id`:

> ns-workers is a shorthand you install once
>
> ns-workers
>
> is a name
>
> you
>
> give the workers plugin's CLI — the scaffold does not create it. Install it once, globally, and every
>
> ns-workers
>
> command on this page works as written:
>
> ```bash
> deno install -gArf -n ns-workers jsr:@netscript/plugin-workers@0.0.1-beta.11/cli
> ```
>
> Rather not install it? Each
>
> ns-workers <verb …>
>
> is exactly
>
> deno x -A jsr:@netscript/plugin-workers@0.0.1-beta.11/cli <verb …>
>
> — run that full form instead.

```bash
# Enqueue through the workers API (port 8091), without hand-writing HTTP.
ns-workers trigger process-payment \
  --payload='{"orderId":"o_42","amountCents":4999}'

# Watch it land in the KV-backed execution history.
ns-workers executions --limit=10 --json
```

## Key types first — `JobHandlerContext` & `JobResult`

A handler is `(ctx: JobHandlerContext<TPayload>) => JobResult<TResult> | Promise<…>`. These two shapes are the contract you write against; read them before the option tables.

**JobHandlerContext — the ctx passed to defineJobHandler**

| Name | Type | Description |
| --- | --- | --- |
| `id` | `string` | The execution id for this run. Stable per dispatched run. |
| `job` | `{ id: string } \| undefined` | The job definition reference (its id), when available. |
| `payload` | `TPayload` | The enqueued payload. Parse it with your Zod schema before use. |
| `correlationId` | `string \| undefined` | Correlation id propagated across the dispatch for tracing. |
| `traceparent` | `string \| undefined` | W3C traceparent of the dispatching span; child spans nest under it. |
| `tracestate` | `string \| undefined` | W3C tracestate accompanying the traceparent. |
| `reportProgress` | `(percent, message?) => void \| Promise` | Optional progress callback wired by the runtime on real runs; emits job.progress events. |

**JobResult — return createSuccessResult() or createFailureResult()**

| Name | Type | Description |
| --- | --- | --- |
| `success` | `true \| false` | Discriminant. createSuccessResult sets true; createFailureResult sets false. Branch on this. |
| `data` | `TResult \| undefined` | The result payload, persisted on the execution record (present on success, optional on failure). |
| `error` | `string` | Failure message — required when success is false (the first arg to createFailureResult). |

## Worker runtime modes (`WORKER_RUNTIMES`)

How a handler is isolated from the API process is a tunable: `WORKER_RUNTIMES` enumerates the three runner modes the worker runtime supports. The scaffold default is **web-worker**, where each worker is its own V8 isolate sized by the `WORKERS_CONCURRENCY` env var. Pick the mode that matches your isolation, memory, and parallelism needs.

**WORKER_RUNTIMES — runner isolation modes (WorkerRuntime type)**

| Name | Type | Description |
| --- | --- | --- |
| `in-process` | `WorkerRuntime` | Runs the handler in the same process via the in-process runner (registry-first). Lowest overhead, no isolation — best for tests, compiled binaries, and single-tenant local composition. |
| `web-worker` | `WorkerRuntime` | Runs each worker in its own Web Worker / V8 isolate (~20-40 MB each). The scaffold default; WORKERS_CONCURRENCY sets the process pool size for parallel job execution. Keep it low to bound memory. |
| `subprocess` | `WorkerRuntime` | Runs the handler in a spawned subprocess. Strongest process isolation; only Deno tasks get permission sandboxing through .permissions(). Python, .NET, shell, PowerShell, and cmd inherit the worker process's OS permissions. |

**Deployment & scaling knobs (workers config)**

| Name | Type | Description |
| --- | --- | --- |
| `WORKERS_CONCURRENCY` | `env (number)` | Runtime worker process pool size. The entrypoint reads this plural variable; current Aspire metadata also emits WORKER_CONCURRENCY, but the runtime does not consume it. |
| `concurrency` | `number` | Per-topic max concurrent workers (WorkersConfigData.concurrency / per-group scaling). |
| `mode` | `'combined' \| 'distributed'` | Per-topic deployment mode: one combined runner vs. distributed runners. Defaults to 'combined'. |
| `queueProvider` | `'auto' \| 'deno-kv' \| 'redis' \| 'postgres' \| 'amqp'` | Queue backend. 'auto' resolves a provider; see Choose a queue provider. |
| `jobsDir / tasksDir` | `string` | Directories scanned for default-exported job and task modules. |

> Tune it without touching code
>
> The runner mode and pool size are deployment settings, not handler concerns — the same
>
> process-payment
>
> handler runs unchanged under any
>
> `WORKER_RUNTIMES` mode
>
> . Start on the
>
> web-worker
>
> default with a small
>
> WORKERS_CONCURRENCY
>
> , move to
>
> subprocess
>
> when you need hard isolation, and drop to
>
> in-process
>
> for tests and compiled single-binary deployments. Resolution precedence (schema default → config file → env → override) is covered in
>
> runtime configuration
>
> .

## Enqueue from a trigger

The HTTP `…/trigger` endpoint is one way in; the other is **declaratively, from a trigger handler**, which returns an `enqueueJob(...)` action. `enqueueJob(job, options)` comes from `@netscript/plugin-triggers-core` and binds an imported job definition to a payload, so an inbound webhook or a scheduled trigger drops work onto this same runtime.

```ts
// plugins/triggers/triggers/payment-webhook.ts
import { defineWebhook, enqueueJob } from '@netscript/plugin-triggers-core';
// Import the job definition you want to dispatch (its default export carries the id).
import processPayment from '../../workers/jobs/process-payment.ts';

const handler = defineWebhook(
  // The handler returns trigger actions; enqueueJob is the most common one.
  (event) => [
    enqueueJob(processPayment, {
      payload: { orderId: event.payload.orderId, amountCents: event.payload.amountCents },
      idempotencyKey: event.payload.orderId, // optional: collapse duplicate deliveries
    }),
  ],
  { id: 'payment-webhook', path: '/webhooks/payment', verifier: 'hmac-sha256', secretEnv: 'WEBHOOK_SECRET' },
);

export default handler;
```

**enqueueJob(job, options) — EnqueueJobOptions (from @netscript/plugin-triggers-core)**

| Name | Type | Description |
| --- | --- | --- |
| `payload` | `TPayload` | The payload handed to the job's ctx.payload. Parse it with the job's Zod schema. |
| `idempotencyKey` | `string` | Collapses duplicate deliveries — at-most-once effect per key. |
| `concurrencyKey` | `string` | Serializes runs that share a key (e.g. per-order) so they do not overlap. |
| `priority` | `number` | Dispatch priority for this enqueue action. |

## Trigger a job from a typed client

The `ns-workers trigger` command above calls the same OpenAPI route. Because the workers API is a **plugin service**, the same trigger is reachable with a generated typed client over the RPC route `/api/rpc/*` — no OpenAPI-only fallback and no transport `404`. A first-party plugin API mounts its router under a named segment, so the client takes a `routerName` alongside `serviceName`; Aspire injects the URL under `services__workers-api__http__0`.

```ts
// Reach the workers plugin API with a generated typed client.
import { createServiceClient } from '@netscript/sdk/client';
import { workersContract } from '@netscript/plugin-workers/contracts';

const workers = createServiceClient<typeof workersContract>({
  contract: workersContract,
  serviceName: 'workers-api',
  routerName: 'workers',
});

// triggerJob is served over /api/rpc/* and returns { jobId, triggered }.
// Over REST the {id} path segment carries the target job; over RPC (no path
// segment) it travels in this input object. Either way it resolves to
// input.id — with no id at all the call fails with a 422 VALIDATION_ERROR.
const { jobId, triggered } = await workers.triggerJob({
  id: 'process-payment',
  payload: { orderId: 'o_42', amountCents: 4999 },
});

// triggerTask is the polyglot-task twin, returning { taskId, triggered }.
const task = await workers.triggerTask({ id: 'validate-payload', payload: {} });
```

**Trigger procedures — served over /api/rpc/* (typed client) and /api/v1/... (OpenAPI/REST)**

| Name | Type | Description |
| --- | --- | --- |
| `triggerJob({ id, payload?, priority?, delay?, correlationId? })` | `{ jobId, triggered }` | POST /jobs/{id}/trigger — enqueue a run of the job named by the {id} path segment; resolves the jobId and a triggered flag. |
| `triggerTask({ id, payload?, priority?, delay?, correlationId? })` | `{ taskId, triggered }` | POST /tasks/{id}/trigger — enqueue a run of the polyglot task named by the {id} path segment; resolves the taskId and a triggered flag. |

> Declare the plugin dependency with pluginReferences
>
> When a Service depends on a plugin API — the workers plugin, say — declare it in that Service's config section with
>
> pluginReferences?: string[]
>
> , a list of the plugin resource names it consumes (alongside the existing
>
> dependsOn
>
> ). The Aspire helper generation wires those references so the Service can discover and load the user jobs the plugin API exposes at runtime, and so the typed client above resolves the plugin's injected URL (
>
> services__workers-api__http__0
>
> ). It is the config seam behind Service→plugin-API job discovery.

> The {id} path segment is authoritative
>
> triggerJob
>
> /
>
> triggerTask
>
> resolve the target id from
>
> input.id
>
> . On the REST route (
>
> POST /jobs/{id}/trigger
>
> ) oRPC merges the
>
> {id}
>
> URL path segment into
>
> input.id
>
> , so the path is authoritative and a body
>
> id
>
> is a redundant fallback that can never disagree with it; on the RPC transport there is no path segment, so the
>
> id
>
> you pass in the input object is used directly. If neither resolves an id, the handler short-circuits to a typed
>
> VALIDATION_ERROR
>
> (HTTP 422) through the centralized
>
> validationFailed
>
> contract helper
>
> before any KV write
>
> — it never persists an
>
> undefined
>
> -keyed run. Handle it as the contract's typed error on the client, not a generic
>
> 500
>
> .

## Graceful shutdown

Background runners must drain in flight work before they exit, or a redeploy loses jobs mid-run. The `@netscript/plugin-workers-core/shutdown` subpath provides a `ShutdownManager` that registers stoppable resources and stops them in priority order, with a timeout, when a shutdown is requested.

```ts
// plugins/workers/bin/with-shutdown.ts
import { ShutdownManager } from '@netscript/plugin-workers-core/shutdown';
import { startWorkers } from '@netscript/plugin-workers-core';

const runtime = await startWorkers({ autoStart: true });
const shutdown = new ShutdownManager({ timeoutMs: 10_000 });

// Register the runtime so a drain stops it gracefully (lower priority stops first).
shutdown.register({ id: 'workers-runtime', priority: 100, stop: (reason) => runtime.stop(reason) });

// Tie OS signals to the drain, then report what stopped / failed / timed out.
Deno.addSignalListener('SIGTERM', async () => {
  const report = await shutdown.shutdown('SIGTERM');
  console.info('shutdown', report.state, { stopped: report.stopped, timedOut: report.timedOut });
  Deno.exit(0);
});
```

**ShutdownManager — @netscript/plugin-workers-core/shutdown**

| Name | Type | Description |
| --- | --- | --- |
| `register(resource)` | `void` | Register a ShutdownResource ({ id, stop(reason?), priority? }) to be stopped on drain. |
| `unregister(id)` | `void` | Remove a resource from the drain set. |
| `shutdown(reason?, { timeoutMs })` | `Promise` | Stop registered resources in priority order; returns { state, stopped, failed, timedOut }. |
| `waitForShutdown()` | `Promise` | Resolves once shutdown has started — await it in long-running loops. |
| `createAbortController()` | `AbortController` | An AbortController that aborts when shutdown begins; pass its signal into in-flight async work. |
| `state` | `'running' \| 'shutting-down' \| 'stopped'` | Current lifecycle state of the manager. |

## Workers API & where jobs come from

Once Aspire is up and the schema is wired, the workers API on **`:8091`** registers your job by `id` and exposes HTTP endpoints to seed, trigger, and inspect runs. These are the endpoints the CLI E2E suite validates live.

**Workers API — port 8091 (full generated surface in the workers reference)**

| Name | Type | Description |
| --- | --- | --- |
| `GET /health` | `liveness` | Health probe for the workers API service. |
| `GET /api/v1/workers/jobs` | `list` | All registered job definitions (id, name, topic) discovered from the jobs directories. |
| `POST /api/v1/workers/jobs/{id}/trigger` | `enqueue` | Enqueue a run of the job with this id, passing a JSON payload body. |
| `GET /api/v1/workers/executions?limit=10` | `history` | Recent executions and outcomes (KV-backed execution state). |
| `GET /api/v1/workers/tasks` | `list` | Task registry view (polyglot defineTask entries). |
| `POST /api/v1/workers/seed` | `seed` | Seed the workers store with the registered jobs. |
| `GET /api/v1/workers/subscribe` | `SSE` | Server-sent-events stream of execution updates (KV-watch). |

> Where jobs come from
>
> Each handler file under your jobs directory (
>
> workers/jobs
>
> by default,
>
> *.ts
>
> ) is discovered and compiled into one generated registry at
>
> .netscript/generated/plugin-workers/job-registry.ts
>
> , keyed by the source filename — so
>
> workers/jobs/process-payment.ts
>
> registers as
>
> process-payment
>
> . Both the
>
> :8091
>
> API service
>
> and
>
> the background runner load that single registry at startup: the API service registers the generated user job definitions
>
> before it serves
>
> , so your jobs — not just the built-in
>
> workers-plugin-health-check
>
> — appear in
>
> GET /api/v1/workers/jobs
>
> and resolve on trigger. Registration order decides id collisions: the plugin's own jobs register first, then the generated user definitions load and
>
> skip any id already present
>
> , so a user file that reuses a built-in id (such as
>
> workers-plugin-health-check
>
> ) leaves the plugin job in place rather than overwriting it — first registration wins. Background execution runs from
>
> plugins/workers/bin/combined.ts
>
> , a
>
> separate
>
> process from the API service — the API enqueues, the runner executes. A missing generated registry is tolerated as an empty set, so a fresh workspace boots before you author any job. Set
>
> WORKERS_CONCURRENCY
>
> on the worker background process when you need a specific process pool size. Current Aspire metadata also emits
>
> WORKER_CONCURRENCY
>
> , but the runtime entrypoint does not consume it; use
>
> Tune the worker runtime
>
> for the mismatch details.

## Observability: real job traces out of the box

Job-level observability is **not** a stub. The workers runtime instruments the scheduler → queue → worker → subprocess path with real OpenTelemetry spans, and those traces appear in the [Aspire dashboard](https://rickylabs.github.io/netscript/explanation/aspire/) automatically once Aspire is up.

**What the worker runtime traces automatically (framework layer — real today)**

| Name | Type | Description |
| --- | --- | --- |
| `Job dispatch + execution` | `span` | Each enqueued run gets a span with attributes, duration, status, and job.started / job.completed / job.failed / job.exception events. |
| `Step + progress events` | `event` | job.step.* and job.progress (current / total / percentage) events are emitted on real job runs. |
| `Subprocess trace continuation` | `context` | W3C traceparent / tracestate is propagated into the subprocess runner, so the child trace links back to the dispatching span. |
| `Scheduler + cron spans` | `span` | Scheduler-start, schedule-job, dispatch, and cron-run spans cover the timer/cron path that fires scheduled jobs. |

For spans you author *inside* a handler, import directly from **`@netscript/telemetry`** (e.g. `@netscript/telemetry/instrumentation` for `withChildSpan`). These nest correctly under the automatic dispatch span. See [Observability](https://rickylabs.github.io/netscript/explanation/observability/) for the model and [Add OpenTelemetry](https://rickylabs.github.io/netscript/observability/how-to/add-opentelemetry/) for the recipe.

## How it compares

Trigger.dev is a managed platform for background jobs and agent workflows; Temporal is a durable-execution system you self-host or rent as Temporal Cloud; NetScript's worker runtime is a plugin that lives inside your own repository and process tree. Each model carries a real tradeoff — a managed platform removes operations, a replay-based cluster gives the strongest durability guarantees, a local-first plugin keeps everything in code you own. The rows below are structural facts drawn from each project's public documentation (as of mid-2026), not rankings.

|  | NetScript workers | Trigger.dev | Temporal |
| --- | --- | --- | --- |
| Handler code | Plain async TypeScript (`defineJobHandler`); failed runs are retried, never replayed | TypeScript tasks in your repo, executed by the platform | Workflow code replayed from event history; side effects live in activities |
| Determinism constraint on orchestration code | None — handlers may call `Date.now()`, `Math.random()`, and do direct I/O | Not required | Required in workflow code: no `Date.now()`, `Math.random()`, or direct I/O |
| Where handlers execute | Your own processes: in-process, web-worker, or subprocess runner | Managed cloud; self-hosting via Docker Compose | Self-hosted cluster or Temporal Cloud |
| Backing stores | Pluggable queue (`deno-kv`, `redis`, `postgres`, `amqp`); Deno KV execution state; Postgres job definitions | Platform-managed | Database, Elasticsearch, server, and workers (self-hosted) |
| Control plane | None separate — the workers API is one of your services (`:8091`) | The Trigger.dev platform | The Temporal cluster |

The practical consequence of the first two rows: a NetScript job handler is ordinary TypeScript with no replay-safety rules to learn, so code an agent writes for a service moves into a worker unchanged — the isolation question is deferred to the runner mode (the `WORKER_RUNTIMES` table above), a deployment setting rather than a handler concern.

## Production notes

> Aspire first, then jobs
>
> The workers plugin persists job definitions to Postgres and uses Deno KV for execution state, so bring orchestration up before you exercise it. Step 1 is the database service; step 2 is Aspire:
>
> cd aspire && aspire start
>
> provisions Postgres and Redis, then
>
> netscript db init --name init
>
> /
>
> netscript db generate
>
> wire the schema. Only after Aspire is up will
>
> :8091
>
> resolve jobs and record executions. See
>
> Database & migration
>
> .

> Drain on redeploy, or lose in-flight runs
>
> A runner that exits without draining abandons jobs mid-execution. Wire a
>
> ShutdownManager
>
> to
>
> SIGTERM
>
> (above), bound it with a
>
> timeoutMs
>
> , and check
>
> report.timedOut
>
> in your logs. Pair the drain with an
>
> idempotencyKey
>
> on enqueue so a retried delivery after a forced exit does not double-charge. For the full recipe see
>
> Tune the worker runtime
>
> .

partial

> Deferred: the scaffold createJobTools helper is a no-op
>
> The scaffold's
>
> createJobTools(ctx)
>
> helper (in
>
> plugins/workers/jobs/job-tools.ts
>
> ) exposes
>
> log
>
> ,
>
> progress
>
> , and
>
> trace
>
> shims — but in the generated copy its
>
> progress and trace methods are no-op stubs
>
> (only
>
> log
>
> writes to the console), and it is
>
> not
>
> a published
>
> @netscript/plugin-workers-core
>
> export. This is a known, tracked limitation with a fix planned, not a permanent design choice, and it is
>
> only
>
> in that scaffold-facing helper: job dispatch and execution themselves emit real spans automatically (above). For custom handler spans today, call
>
> @netscript/telemetry
>
> helpers directly. Do not say "worker tracing is a no-op" — that is false for the framework layer.

## Reference →

This hub is intentionally thin — the full generated API for `@netscript/plugin-workers` (`defineJobHandler`, the job/task builders, the runtime, the shutdown manager, and every exported type and subpath) lives in the reference.

[workers](https://rickylabs.github.io/netscript/netscript/reference/workers/)

[Learn — ERP Sync, lesson 02  Author an import job and trigger it over :8091 as part of the continuous ERP-sync tutorial narrative.](https://rickylabs.github.io/netscript/netscript/tutorials/erp-sync/02-import-job/)

[Do — Tune the worker runtime  Pick the runner mode, set WORKERS_CONCURRENCY, choose a queue provider, and wire graceful shutdown.](https://rickylabs.github.io/netscript/netscript/background-processing/how-to/tune-worker-runtime/)

[Look up — workers reference  The full generated deno doc API for @netscript/plugin-workers — every exported symbol, type, and subpath.](https://rickylabs.github.io/netscript/netscript/reference/workers/)

[Understand — Durability model  How jobs compose with sagas: a job publishes a message a saga consumes. The why behind the choreography.](https://rickylabs.github.io/netscript/netscript/explanation/durability-model/)

[Services & contracts](https://rickylabs.github.io/netscript/netscript/services-sdk/services/) [Durable sagas](https://rickylabs.github.io/netscript/netscript/durable-workflows/sagas/)
