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-pluginWhat 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 source | Why it drifts | Use instead |
|---|---|---|
Date.now() | wall clock, different every replay | await ctx.now() |
performance.now() | monotonic clock, different every replay | await ctx.now() |
new Date() (no args) | wraps Date.now() | new Date(await ctx.now()) |
Date() (plain call) | the current-time string | new Date(await ctx.now()) |
Math.random() | fresh entropy every replay | await ctx.sideEffect(() => Math.random()) |
crypto.randomUUID() | fresh entropy every replay | await ctx.sideEffect(() => crypto.randomUUID()) |
imported randomUUID from node:crypto | fresh entropy every replay | await ctx.sideEffect(() => randomUUID()) |
process.env reads | a redeploy changes the value mid-run | await 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— atry/catchinside 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 aNonDeterminismError. The rule reports a catch over awaited code unless it guards withif (isWorkflowControlFlowSignal(error)) throw erroror 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 globalfetch(...)calls (only the global —this.fetch(...)and other receivers are left alone) and rawengine.*calls (receiver named exactlyengine) 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)(andregisterRemote/registerEntity), where the receiver reads as a workflow engine (engine,workflowEngine,this.engine, …). An unrelatedsomeRegistry.register(...)is left alone. - The class form — the
runmethod of a workflow class: aBaseWorkflowsubclass, or any class carrying astatic 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:
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:
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 throwsNonDeterminismErrorwhen 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.
Python
Python is a first-class durable runtime for Agora too. Implement steps an AdonisJS workflow calls, OR author whole workflows in Python that the Adonis engine drives — both over the same Redis wire, with the engine owning durable state.
Testing
Unit-test workflows with an in-memory engine harness, a clock you control for durable sleep, crash/flaky-step injection, assertions that read the recorded state, and a replay-CI loop (durable:export → parseRunHistory → assertReplayable) that fails the build on a determinism break — no Postgres, no Redis, no real time.