Agora
Tooling

Linting for non-determinism

Catch Date.now(), Math.random(), process.env, swallowed control-flow signals and direct I/O inside a workflow body at author time with @adonis-agora/durable-eslint-plugin — AST-scoped ESLint rules that know both the engine.register() function form and a BaseWorkflow class.

A durable workflow body is executed and replayed: the engine re-runs it from the top on every resume, replaying recorded step results to reach the suspension point. That only stays correct if the body is deterministic — the same input has to produce the same sequence of step calls every time. A bare Date.now(), Math.random(), new Date(), crypto.randomUUID(), or performance.now() returns a different value on every replay, so the replayed path diverges from the recorded one and silently corrupts the run.

The engine already detects this drift at runtime and throws a NonDeterminismError — but that's a replay-time failure, often far from where the offending call lives. @adonis-agora/durable-eslint-plugin moves the check to author time: it flags those calls in your editor and CI, before they ever reach a replay. See Versioning & determinism for why determinism matters and how the runtime guard works.

npm i -D @adonis-agora/durable-eslint-plugin

What it flags

Inside a workflow body, these are banned, and the fix is the engine's checkpointed equivalent — each is recorded once on the first execution and replayed verbatim afterwards:

Banned sourceWhy it driftsUse instead
Date.now()wall clock, different every replayawait ctx.now()
performance.now()monotonic clock, different every replayawait ctx.now()
new Date() (no args)wraps Date.now()new Date(await ctx.now())
Date() (plain call)the current-time stringnew Date(await ctx.now())
Math.random()fresh entropy every replayawait ctx.sideEffect(() => Math.random())
crypto.randomUUID()fresh entropy every replayawait ctx.sideEffect(() => crypto.randomUUID())
imported randomUUID from node:cryptofresh entropy every replayawait ctx.sideEffect(() => randomUUID())
process.env readsa redeploy changes the value mid-runawait ctx.sideEffect(() => process.env.X), or resolve config outside the workflow

The rule also sees through the easy disguises: import { randomUUID as uuid } aliases, a default/namespace crypto import, and a same-file const d = Date; d.now() alias (simple const tracking — no data-flow analysis).

// ✗ corrupts the run on replay
engine.register('checkout', '1', async (ctx, order) => {
  const startedAt = Date.now()                // ✗ useNow
  const idempotencyKey = crypto.randomUUID()  // ✗ useUuid
  const jitter = Math.random()                // ✗ useRandom
  const at = new Date()                       // ✗ useNowDate
})

// ✓ deterministic — recorded once, then replayed
engine.register('checkout', '1', async (ctx, order) => {
  const startedAt = await ctx.now()
  const idempotencyKey = await ctx.sideEffect(() => crypto.randomUUID())
  const jitter = await ctx.sideEffect(() => Math.random())
  const at = new Date(await ctx.now())
})

ctx.now() covers timestamps; ctx.sideEffect(fn) captures any other generated value once and replays it verbatim — pass your own generator.

Two more rules: swallowed signals and direct I/O

Non-determinism isn't the only way a workflow body silently breaks — the plugin ships two companion rules, both on in the recommended preset:

  • rethrow-control-flow-signals — a try/catch inside the workflow body must let the engine's control-flow signals through (see Catching errors). Suspend-the-run operations unwind the turn by throwing; a catch that swallows one breaks suspension silently and the resumed replay dies with a NonDeterminismError. The rule reports a catch over awaited code unless it guards with if (isWorkflowControlFlowSignal(error)) throw error or rethrows the caught error unconditionally — and offers an editor suggestion inserting the guard as the first statement. A try over purely synchronous code (try { JSON.parse(raw) } catch { … }) is left alone: it can never catch a signal.

  • no-io-in-workflow-body — the orchestration body re-executes on every replay, so un-checkpointed I/O runs again each time. The rule flags global fetch(...) calls (only the global — this.fetch(...) and other receivers are left alone) and raw engine.* calls (receiver named exactly engine) inside the workflow body. Wrap the I/O in a checkpointed step — await ctx.localStep('get', () => fetch(url)) — or drive the engine from outside the workflow.

What it knows about a workflow body

Every rule in the plugin is AST-scoped — it only fires inside a workflow body, recognizing both forms Agora supports:

  • The function form — the function passed to engine.register(name, version, fn) (and registerRemote / registerEntity), where the receiver reads as a workflow engine (engine, workflowEngine, this.engine, …). An unrelated someRegistry.register(...) is left alone.
  • The class form — the run method of a workflow class: a BaseWorkflow subclass, or any class carrying a static workflow = { name, … } config.

Crucially, the rules stop at a ctx.localStep(...), ctx.task(...), or ctx.sideEffect(...) callback boundary: those are the checkpoint-callback primitives whose body runs once and whose whole result is recorded, so a Date.now(), a try/catch, or a fetch() inside one is fine. (ctx.step is not a boundary — it is the always-dispatched step whose second argument is data, not a callback.) Only the deterministic orchestration prefix is checked, so you can enable the rules across your whole **/*.ts glob without false positives.

ESLint (flat config)

Wire it into your flat config under the plugin namespace:

eslint.config.js
import durable from '@adonis-agora/durable-eslint-plugin'

export default [
  {
    files: ['**/*.ts'],
    plugins: { '@adonis-agora/durable': durable },
    rules: {
      '@adonis-agora/durable/no-nondeterminism': 'error',
      '@adonis-agora/durable/rethrow-control-flow-signals': 'error',
      '@adonis-agora/durable/no-io-in-workflow-body': 'error',
    },
  },
]

Or spread the shipped preset, which enables every rule at error for you:

eslint.config.js
import durable from '@adonis-agora/durable-eslint-plugin'

export default [
  durable.configs.recommended,
]

Each report points at the exact call and names its deterministic replacement, e.g. "Non-deterministic Date.now() inside a durable workflow body — use ctx.now() (recorded once, then replayed)."

How it complements NonDeterminismError

The lint rule and the runtime guard are two layers of the same defense:

  • Author time (this plugin). The drift is caught as a red squiggle / a failing CI step, pointing at the exact source line. You fix it before it ships.
  • Replay time (NonDeterminismError). If a non-deterministic call slips through — say it came from a transitive helper the linter couldn't see — the engine still refuses to corrupt the run and throws NonDeterminismError when the replayed path diverges from the recorded one.

The lint rule is the cheaper, earlier signal; the runtime error is the backstop. Together they keep a durable run deterministic. For how recorded-vs-replayed history is compared, and how to evolve a workflow's body safely once it's in production, see Versioning & determinism.

On this page