A run can wait on a human for days. It should cost nothing while it waits, and survive the server dying.
An agent that asks a person to approve something, then blocks a request handler until they answer, is a memory leak with a spinner. The durable background runner inverts it: the turn runs in durable infrastructure, and when it hits an ask it parks with zero compute, survives process death, and resumes later — minutes or a week — exactly where it stopped. One vendor-agnostic seam covers three backends, and a deployment that never enqueues a durable run is byte-identical.
What durable means here
"Durable" is a precise, two-part claim about an ask-suspended run. First, while it waits it consumes no compute — it is not a held-open promise burning a function invocation, it is a checkpointed continuation the vendor holds. Second, it outlives the process that started it: deploy, scale to zero, or crash, and when the human finally answers the run picks up mid-turn. The interactive runner (@nightowlsdev/runner-nextjs) streams the engine's own iterable straight to the browser and dies with the request; this runner is the home of the story where the turn outlives the request.
Durable needs two things, not one. A durable backend checkpoints the parked task — but resuming it re-runs engine.resume(...), which needs the saved Mastra snapshot. So durability also needs a cross-process StorageAdapter (Postgres via @nightowlsdev/storage-supabase). The in-memory backend is genuinely durable within one process — but a restart drops its in-flight continuations, so it is not durable across a restart. Pair a cross-process backend with an in-memory StorageAdapter and the waitpoint survives while the snapshot to resume it does not: createBackgroundRunner warns at construction for exactly that combination.
The DurableBackend seam: three verbs
Every vendor is reduced to three verbs (CONTRACTS §6): enqueueRun schedules the task body, createWaitpoint opens a parkable waitpoint inside it, and completeWaitpoint wakes a parked task from the host process. That is the entire surface — which is why the same runner drives Trigger, Vercel, and an in-memory double, and why the whole park→resume loop can be tested with no cloud at all.
import type { RunInput, SwarmContext } from "@nightowlsdev/core";
// The whole vendor surface reduces to THREE verbs, so the runner is vendor-agnostic and
// hermetically testable. Everything below — Trigger, Vercel, in-memory — implements only this.
interface DurableBackend {
// Schedule the durable task body. Returns when ACCEPTED, not when done.
enqueueRun(body: DurableTaskBody, input: RunInput, ctx: SwarmContext): Promise<void>;
// Open a parkable waitpoint INSIDE the task. `key` (= followupId) addresses it and is persisted so
// the resume path can read it back. `idempotencySalt` (FR-065) disambiguates successive suspensions
// that share a key — see step 07.
createWaitpoint<T>(opts: { key: string; timeout?: string; idempotencySalt?: string | number }): Promise<Waitpoint<T>>;
// Wake a parked task from the HOST process by the waitpoint id; idempotent (a double-complete is a
// no-op). Returns `true` when it woke a continuation this backend can actually resume — always true
// for Trigger/Vercel (the parked task survives independently of the host). The in-memory backend
// returns `false` when no live in-process waiter exists (e.g. after a restart), signalling the caller
// to recover the continuation from the durable Postgres snapshot instead.
completeWaitpoint(id: string, data: unknown): Promise<boolean>;
}
// A waitpoint handle: park() suspends the task with ZERO compute on real backends until it is completed.
interface Waitpoint<T> {
readonly id: string;
park(): Promise<{ ok: true; output: T } | { ok: false; reason: "timeout" | "cancelled" }>;
}The runner builds the park→resume loop on top of those verbs. It runs in the vendor's infra; the HTTP caller never touches the task's output, it subscribes to the persisted events over Realtime.
// createBackgroundRunner({ engine, storage, backend }) returns the core Runner. Its durable TASK BODY
// is a loop that runs in the vendor's infra (a DIFFERENT process from the HTTP caller):
//
// 1. engine.run(input, ctx) streams events. On an `ask` tool suspend the engine saves the Mastra
// snapshot to Postgres, sets the run `suspended`, and emits `swarm.question`. The task body only
// READS that event — the engine is the sole event writer.
//
// 2. backend.createWaitpoint({ key: followupId, timeout: askTimeout }) -> persist its id via
// storage.runs.attachWaitpoint?(...) -> park(). The task now consumes ZERO compute while parked
// (Trigger checkpoints across process death; Vercel parks on a hook).
//
// 3. When the host answers, park() resolves; the body calls engine.resume(...) and LOOPS. resume can
// itself emit another question, so N sequential HITL turns run in ONE durable run. It terminates
// only on a terminal status (done / failed).
//
// The HTTP caller never streams the task's output directly. It subscribes to the persisted swarm_events
// over Realtime: run(input, ctx) = enqueue(...) then yield* storage.events.subscribe(runId).
//
// askTimeout defaults to "7d" — Trigger's ~10-minute default is far too short for a human in the loop.Three backends, and when to use which
Three implementations ship from the single entry point. @trigger.dev/sdk and workflow are both optional peers: the package builds, installs, and imports without either present, and you wire exactly one.
| Backend | Factory | Use when | Trade |
|---|---|---|---|
| Trigger v4 GA | createTriggerBackend | production; long engine segments; the durable path we ship as GA | needs a deployed Trigger task + account; the engine bundles into the task |
| Vercel Workflow | createVercelBackend + swarmWorkflow | Vercel-native deployments; short engine segments | ~800s @workflow/next step ceiling; the FR-065 waitpoint salt is a tracked follow-up on this path |
| in-memory | createInMemoryBackend | local dev; a single-process host; the hermetic test suite | NOT durable across a restart — a process restart drops in-flight continuations |
The in-memory backend backs each waitpoint with a held-open promise, so the full enqueue → park → resume loop runs in-process against the real @nightowlsdev/core engine — no Docker, no cloud. After a restart its completeWaitpoint returns false (the waiter is gone), which tells the runner to recover the continuation from the durable Postgres snapshot instead.
Trigger.dev v4 — durable tasks + wait tokens (the GA layer)
Trigger is the GA durable layer, and it is worth being exact about which Trigger primitive it rides. The runner parks on Trigger v4 durable tasks + wait tokens — wait.createToken(...), wait.forToken(...), and wait.completeToken(...). A token is created inside the task, awaited (which parks the task with zero compute), and completed from the host process; it is idempotent server-side, so a duplicate resume is a no-op.
This is NOT chat.agent. Trigger's chat.agent Sessions surface is a different thing: it backs the experimental @nightowlsdev/engine-trigger-chat engine, a remote adapter that renders a deployed chat.agent Session as a Night Owls agent (reduced-governance, tier-2, its loop runs inside the vendor). It is not the GA durable primitive, and the durable runner does not use it. When this page says "Trigger" it means durable tasks and wait tokens — the primitive layer — never the chat.agent Sessions layer.
Wiring is three files, because the deployed task must bundle your engine (a live object that cannot travel over the wire). A shared config module registers an engine+storage factory that both the task file and the app import.
// swarm.config.ts — imported by BOTH the deployed task file AND the app.
// An engine is a live object (a Mastra instance + a pg pool); it cannot travel over the wire, so the
// host registers a factory once, at module import, in a module both sides share.
import { registerBackgroundSwarm } from "@nightowlsdev/runner-background";
import { SwarmEngine } from "@nightowlsdev/core";
import { createSupabaseStorage } from "@nightowlsdev/storage-supabase";
registerBackgroundSwarm(() => ({
engine: new SwarmEngine({ /* … model, tools, agents … */ }),
// Build the SAME storage as the app, on the DIRECT Postgres port 5432 (NEVER the 6543 pooler) so a
// resumed run finds the saved snapshot.
storage: createSupabaseStorage({ dbUrl: process.env.DATABASE_URL_5432! }),
}));// trigger/swarm.ts — the deployed task file. runDurableTask runs the park->resume loop DIRECTLY inside
// Trigger's infra (it does NOT re-enqueue — the host already triggered the task).
import { task } from "@trigger.dev/sdk";
import { runDurableTask } from "@nightowlsdev/runner-background";
import "../swarm.config"; // runs registerBackgroundSwarm at import
export const swarmRun = task({
id: "nightowls.swarm.run",
retry: { maxAttempts: 1 }, // §12 — the backend is the SINGLE retry owner (Mastra step retries are off)
maxDuration: 3600,
run: (payload) => runDurableTask(swarmRun, payload),
});
// lib/swarm.ts — the app side.
import { createBackgroundRunner, createTriggerBackend, getRegisteredSwarm } from "@nightowlsdev/runner-background";
import { swarmRun } from "../trigger/swarm";
const { engine, storage } = getRegisteredSwarm();
export const runner = createBackgroundRunner({
engine,
storage,
backend: createTriggerBackend(swarmRun), // wait.createToken / forToken / completeToken under the hood
retries: { owner: "backend" }, // asserted at construction; owner !== "backend" THROWS
});createBackgroundRunner asserts the backend is the single retry owner (§12): passing retries.owner !== "backend" throws, because two retry owners would double-execute side effects. The waitpoint's idempotency key is the followupId, so a retried task attempt resumes the same waitpoint rather than opening a second.
Vercel Workflow — the in-package swarmWorkflow subpath
For Vercel-native deployments, FR-061 ships an in-package durable body, swarmWorkflow, from the dedicated @nightowlsdev/runner-background/vercel-workflow subpath. It is a parallel orchestrator, not a wrapper around the shared loop: a Vercel 'use step' is a durable-RPC boundary, so all engine / DB / egress work lives in 'use step' closures and only the durable createHook park lives in the 'use workflow' body. The backend factory, createVercelBackend, takes its createHook / resume / cancelWorkflow callables injected by the host — it never hard-imports workflow.
// app/workflows/swarm.ts — the deployed durable body. swarmWorkflow ships from its OWN subpath (never
// the main barrel) because it carries 'use workflow' / 'use step' directives: @workflow/next un-externalises
// ANY package it finds a directive in, and doing that to the whole package would drag pg + core + Mastra into
// Next's server bundle. Import it from the subpath so the main barrel stays directive-free.
import "./swarm.config"; // registerBackgroundSwarm(() => ({ engine, storage }))
export { swarmWorkflow } from "@nightowlsdev/runner-background/vercel-workflow";
// lib/swarm.ts — the runner backend. createVercelBackend takes its callables INJECTED (it never
// hard-imports `workflow`), so the package builds without the optional peer.
import { createBackgroundRunner, createVercelBackend } from "@nightowlsdev/runner-background";
import { startWorkflow, getRun } from "workflow";
import { createHook, resume } from "./hooks"; // your defineHook<AnswerPayload>() pair
import { swarmWorkflow } from "../app/workflows/swarm";
const backend = createVercelBackend({
// Launch with a deterministic id = ctx.runId, so cancelWorkflow(runId) is just getRun(runId).cancel().
startWorkflow: (p) => startWorkflow(swarmWorkflow, p, { workflowId: p.ctx.runId }),
cancelWorkflow: (id) => getRun(id).cancel(), // omit it and the deadline sweep / cost ceiling can't stop a runaway
createHook,
resume, // for THIS in-package path the injected pair is vestigial — the park lives inside swarmWorkflow
});
export const runner = createBackgroundRunner({ engine, storage, backend });The ~800s step ceiling is a platform limit, not a bug. @workflow/next writes steps: { maxDuration: 'max' } (~800s on Fluid Pro) with no per-step override, while Trigger sets 3600s. One engine segment is one step invocation, and a segment that cannot finish is not retryable into success (it would replay tool calls that already ran). Long engine segments still need Trigger — deploy those there. This driver is best-effort + gated-smoke only; verify the injected callables against your installed workflow types before relying on it in production.
The stuck-run watchdog (the reaper)
Durability has an edge case: a run whose task crashed, hung, or was stranded by a restart stays running forever — an in-flight ghost with a spinner that never resolves. The reaper sweeps for those (a running run with no new event past a cutoff, default 15m; suspended HITL runs are exempt, they park legitimately) and resolves each cleanly: marks it failed and appends run_failed{ stage: "stalled", retryable: true } so a watching client's spinner flips to "stalled — retry". It is billing-agnostic; per reaped run it fires an optional onStalled hook where the host wires settle/refund.
import { reapStuckRuns, startReaper } from "@nightowlsdev/runner-background";
// A `running` run only transitions on done / suspended / failed. A run whose task CRASHED, hung, or was
// stranded by a process restart stays `running` forever — an in-flight ghost. The reaper sweeps for those
// (a `running` run with no new event past a cutoff — default 15m; `suspended` runs are EXEMPT), marks each
// `failed` + appends `run_failed{ stage: "stalled", retryable: true }` so a watching client's spinner flips
// to "stalled — retry" instead of spinning forever. It is BILLING-AGNOSTIC — never touches money.
// Long-lived server (dev / non-serverless): the unref'd, globalThis-guarded interval.
startReaper(
{ storage, pool /* a pg Pool whose search_path resolves the engine runs/events tables */ },
{
intervalMs: 5 * 60_000,
maxAgeMs: 15 * 60_000,
onStalled: async ({ runId, tenantId }) => { // the HOST's billing seam — never charge for non-delivery
if (!tenantId) return;
await credits.settle(tenantId, runId);
await credits.refund(tenantId, runId);
},
// FR-064 — the per-run FAILURE contract. Fired once per run the sweep could NOT reap (its
// setStatus/append/capture threw). This NAMES the still-stuck runs instead of leaving you to
// infer them from a log line.
onReapError: ({ runId, error }) => alerts.page(`reap failed for run ${runId}`, error),
},
);
// Serverless: the interval can't run — schedule a cron route instead.
const reaped = await reapStuckRuns({ storage, pool }, { onStalled, onReapError });
// FR-064 — `reaped` is the count actually REAPED, not SELECTED. `return rows.length` used to report the
// full count even when every reap failed, so the watchdog reported MORE success the worse things got
// (a permissions change / pooler-at-cap ⇒ a sweep that reaped 0 of 12 answered { reaped: 12 }, 200,
// green). Now `selected − reaped` is the failure count and onReapError names each failure.FR-064 — the watchdog reports honestly now. reapStuckRuns returns the count actually reaped, not the count selected. The old return rows.length reported the full count even when every reap failed, so the stuck-run watchdog reported more success the worse things got — a permissions change or a pooler at capacity could make a sweep that reaped 0 of 12 answer { reaped: 12 }, 200, green. Now selected − reaped is the failure count, and the new onReapError({ runId, error }) hook names each run the sweep could not reap — the contract that replaces inferring failures from a log line.
startReaper runs the interval in a long-lived process (unref'd, globalThis-guarded so HMR cannot stack intervals). Serverless hosts can't run an interval, so schedule a cron route instead: createReaperHandler from @nightowlsdev/runner-nextjs returns a GET that returns { reaped }.
// app/api/swarm/reap/route.ts — the serverless reaper endpoint. Point a scheduler (Vercel Cron,
// GitHub Actions, any pinger) at it every few minutes.
import { createReaperHandler } from "@nightowlsdev/runner-nextjs";
import { reapStuckRuns } from "@nightowlsdev/runner-background";
export const { GET } = createReaperHandler({
reap: () => reapStuckRuns({ storage, pool }, { onStalled, onReapError }), // returns { reaped }
// Optional: open in dev (the sweep only ever FAILS already-stalled runs, so it is safe unauthenticated).
// Set it in prod and the request must carry `Authorization: Bearer <secret>`.
cronSecret: process.env.CRON_SECRET,
});Waitpoint integrity — one suspension, one token
Because N sequential HITL turns run in one durable run, one followupId can be suspended more than once — a repeated approval, a re-asked tool whose toolCallId Mastra reuses, a re-hit cost cap. FR-065 is the fix for the resulting hazard: Trigger's createToken is idempotent on the key alone, so a re-suspended followup would be handed the previous suspension's already-completed token — silently auto-answering the new ask with the earlier reply. The runner salts the durable waitpoint per suspension.
// FR-065 — the durable waitpoint is salted PER SUSPENSION. Trigger's createToken is idempotent on the
// idempotency key ALONE, so a re-suspended followup that reuses the same key (a repeated approval, a
// Mastra-reused toolCallId, a re-hit cost cap) would otherwise be handed the PREVIOUS suspension's
// already-completed token — auto-answering the NEW ask with the earlier reply. The runner folds in a
// monotonic-per-suspension, retry-stable salt (the engine's segment generation index):
const idempotencyKey = idempotencySalt != null ? `${key}#${idempotencySalt}` : key;
await wait.createToken({ idempotencyKey, timeout: askTimeout ?? "7d" });
// A retry of the SAME suspension keeps the same salt (it is snapshot-derived) and resumes the same
// waitpoint; a genuine RE-suspension gets a strictly greater salt and a FRESH token. The salt is resolved
// engine-agnostically: the default Mastra engine stamps it on the question (data.generationIndex, the fast
// path); other engines write the same counter to the snapshot as genIndex, which the runner reads back —
// so no engine can silently reintroduce the bypass. Absent salt (a pre-FR-065 snapshot) ⇒ key-only
// idempotency, i.e. the legacy behaviour.Automatic on Trigger and in-memory. The salt is the engine's monotonic-per-suspension, retry-stable generation index, resolved engine-agnostically (the Mastra fast path stamps it on the question; other engines write it to the snapshot as genIndex, which the runner reads back). A task retry of the same suspension keeps the salt and resumes the same waitpoint; a genuine re-suspension gets a fresh one. You wire nothing.
⚠ The Vercel hook path is a tracked follow-up. The in-package swarmWorkflow body parks on the raw followupId (unsalted). The FR-065 bypass rides Trigger's key-idempotent createToken; whether Vercel's createHook({ token }) reuses a completed hook for a re-used token within one run is unverified (a position-based park would make it benign), and salting there needs a symmetric change on the resume side plus a live deploy to verify. This driver is pre-production and smoke-gated, so the salt is deferred there rather than half-applied.
API reference
- createBackgroundRunner({ engine, storage, backend, askTimeout?, retries? }) → the core Runner. enqueue / run / resume / cancel. Asserts retries.owner === "backend" and warns when a cross-process backend is paired with a non-durable engine store. askTimeout defaults to "7d".
- DurableBackend / Waitpoint<T> / DurableTaskBody — the three-verb seam and its handle types.
- createTriggerBackend(task) + runDurableTask(task, payload) — the Trigger v4 backend (lazy-imports @trigger.dev/sdk) and the taskrun body. Plus registerBackgroundSwarm / getRegisteredSwarm, the shared factory.
- createVercelBackend({ startWorkflow, createHook, resume, cancelWorkflow? }) and swarmWorkflow (from @nightowlsdev/runner-background/vercel-workflow) — the Vercel Workflow backend and its in-package durable body [FR-061].
- createInMemoryBackend() / InMemoryDurableBackend — the in-process backend (a .settle() awaits all task bodies in tests). Not durable across a restart.
- reapStuckRuns(deps, opts?) → the count reaped [FR-064]; startReaper(deps, opts?) the in-process interval. onStalled (billing) and onReapError (per-run failure) are the two host hooks. Serverless: createReaperHandler({ reap, cronSecret? }) from @nightowlsdev/runner-nextjs.
- verifyDurableLifecycle(opts) — the vendor-agnostic enqueue → suspend → complete → done verifier; drives only the public seams, so the same function covers the in-memory backend (hermetic) and a deployed Trigger/Vercel task (the NIGHTOWLS_TRIGGER_SMOKE smoke).
Where to go next
The recurrence plane
The timer that lives above this seam: register a schedule, fan it out, and enqueue a durable run at-most-once per occurrence — the reaper is its first tenant.
Approval modes
Why a run parks in the first place: the tool risk vector and the manual/auto/permissive gate that turns a dangerous call into a swarm.question.
The task-manager plane
Run an agent against a task unattended, behind a resume-safe cost ceiling that sums usage across every durable resume segment — the reason a per-run budget is not enough.
Building on Night Owls? See the source on GitHub.