Handshake & negotiation
How a mixed fleet stays version-safe — workers advertise a capability descriptor, the control plane negotiates compatibility, and work routes only to workers that can run it. Runs park blocked instead of hanging.
In a split or polyglot fleet, workers come and go and may run different versions of the SDK — a NestJS worker, a Python worker, an Adonis worker, an older release. The handshake layer keeps that fleet honest: every worker advertises what it can do, the control plane negotiates compatibility before dispatching, and a run that no live worker can execute parks as blocked rather than hanging silently.
The handshake is broker-native — there is no direct connection between the control plane and a worker. Workers advertise their descriptor on the same heartbeat registry the control plane already scans, and the control plane validates it on read. It works across the shared wire, so the negotiation is identical whether the worker is NestJS, Adonis, or Python.
The worker descriptor
Every worker publishes a WorkerDescriptor — the single source of truth for routing, compatibility, and observability:
interface WorkerDescriptor {
instanceId: string
runtime: 'node' | 'python'
sdk: { name: string; version: string }
protocol: { version: number; range: [number, number] } // wire-protocol majors it speaks
capabilities: string[] // named features: 'saga', 'signals', 'priority', 'child-workflows', ...
workflows: string[] // registered handler names → routing
steps: string[]
registrations?: WorkflowRegistration[] // what this worker can EXECUTE — see below
partition?: string
namespace?: string
startedAt: number
}It is advertised in two tiers to stay cheap at steady state and rich on change (ETag-style):
- Heartbeat (every ~10s,
EX 35): a compact{ ts, status, descriptorHash }. - Full descriptor: published on startup and whenever it changes, to
${P}-worker-descriptor:<token>:<instance>. The control plane re-reads the full descriptor only when thedescriptorHashchanges.
Negotiation — three outcomes
The control plane self-advertises its own descriptor, and both sides compute a negotiated session: the highest common protocol major plus the capability intersection. Each worker lands in one of three states:
| Outcome | Meaning | Dispatch |
|---|---|---|
| Compatible | protocol ranges overlap + required capabilities present | dispatch freely |
| Degraded | ranges overlap but an optional capability is missing | dispatch, but route capability-requiring work only to capable workers; soft warning |
| Incompatible | no protocol-range overlap | do not dispatch — red flag with the exact reason |
An incompatible worker (say it speaks protocol 2 while the control plane speaks 1) is never handed a task — the mismatch surfaces loudly instead of corrupting a run.
Capability-aware routing
A workflow or step can declare the capabilities it requires on its decorator:
@Injectable()
export class PaymentSteps {
@Step({ name: 'settle-payment', requires: ['saga', 'search-attributes'] })
async settle(input: { orderId: number }) { /* ... */ }
}@Workflow({ requires: [...] }) works the same way. The control plane dispatches such work only to workers whose descriptor advertises those capabilities. If no live capable worker exists, the run does not hang and does not dead-letter — it parks as blocked:
blocked: no compatible worker (requires 'saga') is a first-class run status, visible in the dashboard. The control plane re-checks blocked runs on a poll, so the moment a capable worker registers, the run resumes automatically. A capability mismatch is never a silent hang.
What workflows exist in this deployment?
A console that lets somebody assemble a pipeline needs a picker: which existing workflows can this
node call? The engine's own registry cannot answer that. engine.workflowBody(name, version)
answers for the process you happen to ask, and a missing body is ambiguous — it means "not
registered here", but equally "registered via registerRemote against another SDK" or "a group this
pod resolves by convention against a live worker". A picker built on that inference would show
different options depending on which replica served the request.
So registration is announced, not inferred. A worker publishes what it can execute as part of the descriptor it already advertises:
interface WorkflowRegistration {
name: string
version?: string // absent = the announcer did not state one
group?: string // the queue this worker consumes for it
requires?: string[]
origin?: string // the package that declared the workflow
}The rule is announce only what you can run. A worker publishes a registration for a body it holds and a queue it consumes; it never publishes one for a workflow it merely knows how to route to. That is what removes the ambiguity: an entry in the registry means a live process said "I can run this", not "somebody, somewhere, might".
The announcer is always the process that consumes the queue — including when an engine and a worker
share one process, where the in-app worker announces and the engine
beside it does not. So a pure operator (store with no connection) announces nothing: it runs
its bodies inline, consumes no queue, and has no routing token to offer. Its workflows are real and
startable through its own start(); they are simply not something an external picker can point at.
Read the aggregate from any pod:
const announced = await engine.announcedWorkflows()
// [{ key: 'pipeline@2', name: 'pipeline', version: '2', groups: ['pipeline'],
// origins: ['flip-python-db'], requires: [['saga']], runtimes: ['python'],
// instances: ['py-1', 'py-2'], disagreements: [] }]It costs one scan of the advertisement keyspace per call — nothing is added to the poll loop, and
no pod keeps a table of the fleet's registrations in memory. It is scoped like every other poll
surface: an engine with a namespace sees only its own tenant, an operator (namespace unset) sees
everything.
Liveness
An announcement rides the descriptor key, which is written with the worker-heartbeat TTL (EX 35)
and refreshed by the same ~10s beat. A worker that dies stops refreshing, the key expires, and its
announcements disappear with it — there is no expiry bookkeeping anywhere in the engine, and no way
for an announcement to outlive its worker. The resolution of that liveness is the TTL, so an entry
can name a worker that died within the last beat window; that is the same staleness the capability
router already accepts when it reads descriptors to decide whether to dispatch.
Disagreement
Two live workers can announce the same name@version with different groups, origins or capability
demands. The registry reports every distinct claim and picks none of them:
{ key: 'pipeline@2', groups: ['pipeline', 'pipeline-v2'],
disagreements: [{ axis: 'group', values: ['pipeline', 'pipeline-v2'] }] }Two origins for one name@version is a name collision between packages; two requires sets is two
different code versions under one version tag; two groups means a caller cannot know which queue to
dispatch to. Any of those is something a human has to resolve, so the aggregate surfaces it rather
than merging it away. Silence is not a claim — a worker that announced no origin does not
disagree with one that did.
Cross-SDK
A Python worker participates. Every SDK already publishes workflows: string[], and a bare name is
accepted as a valid (unversioned, group-less) announcement, so a worker that has not adopted
registrations is still listed rather than invisible. @worker.workflow("pipeline", version="2", origin="flip-python-db") fills in the richer axes; what you leave out is announced as un-stated, and
the aggregate never invents a version, group or origin nobody stated.
A worker that announces nothing at all
The paragraph above holds for a worker that publishes a descriptor. A worker on an SDK old enough to
predate the descriptor advertisement publishes only its liveness heartbeat — one key per routing
token it consumes, and no durable-worker-descriptor: value anywhere. Read strictly, such a fleet
announces nothing, and the registry used to report it as empty while it was serving work.
That answer was not merely unhelpful, it contradicted the engine beside it: convention routing
resolves a call to X the moment a token named X heartbeats, so the deployment already treated
liveness as sufficient to call a workflow while claiming to know nothing when asked to list one —
two answers out of the same Redis.
So the heartbeat is a second, weaker tier of evidence, and every entry says which tier it rests on:
const { workflows } = await engine.workflowDirectory()
// [{ evidence: 'declared', key: 'pipeline@2', version: '2', runtimes: ['python'], … },
// { evidence: 'observed', key: 'processing', groups: ['processing'], instances: ['py-1'],
// version: undefined, origins: [], requires: [], runtimes: [] }]An observed entry exists because a live token of that name exists — precisely the condition under
which convention routing would resolve it, so listing it cannot introduce a failure the dispatcher
did not already have. It states nothing it cannot know: no version, no origin, no runtime, and no
assurance that the token serves a workflow rather than a step handler of the same name. A descriptor
always wins, and a token some worker declared as a step is excluded outright — that is a stated
negative an observation cannot supply for itself, and it is what keeps a route-by-handler fleet's
entire step surface out of the picker.
Why it is empty
An empty list has three honest meanings, and returning [] for all of them hides the two that need
acting on. workflowDirectory() separates them:
supported | workflows | means | do |
|---|---|---|---|
false | [] | no transport here can introspect — nobody asked | configure a transport |
true | [] | the fleet was asked and nothing is live | start a worker |
true | populated | here it is | — |
A fourth hides inside the second and is the most expensive to debug, because from the caller's side
it is identical to "nothing is running": a worker live on a partition this engine does not
route to. It consumes <token>@<partition>, convention resolution computes the token for this
engine's namespace and misses. The heartbeat keyspace is shared, so the evidence is right there, and
otherPartitions reports it rather than letting the caller conclude absence:
{ supported: true, workflows: [], otherPartitions: [
{ partition: 'davi-local', groups: ['processing@davi-local', …], instances: ['py-1'] } ] }announcedWorkflows() is unchanged as a call and now returns the directory's workflows, so an
existing picker gets the visibility fix without touching its code.
Steps are out of scope, deliberately
There is no equivalent registry for steps, and there never will be one shaped like this. A step is
not addressable from outside a run: it is identified by its (runId, seq) position in one
workflow's history, ctx.step is only callable from inside a replaying body, and no engine entry
point starts a step on its own. "Call this step" is not an operation the engine can perform, so a
picker offering steps would be offering something that does not exist. Step handler names still ride
steps in the descriptor — that is a routing and capability fact, not an invocable catalog.
Loud, structured failures
When negotiation rejects a worker or a run parks, durable emits structured diagnostics — protocol.incompatible / capability.unavailable — carrying both descriptors and the precise delta, not a bare boolean. Those events flow to the dashboard health panel and to Telescope, and are alertable.
Backward compatibility
A worker that advertises no descriptor (an older SDK release) is treated as legacy v1 with the v1 capability baseline, and assumed compatible. Existing workers keep flowing untouched; rich negotiation lights up as SDKs adopt it. It is also still visible — see a worker that announces nothing at all — because a callee should never have to change to be callable, and that has to include being findable.
The current wire protocol is v1. The whole point of the handshake is that a future v2 breaking change is detectable — an old worker and a new control plane discover the mismatch and refuse to corrupt each other, instead of failing in confusing ways at runtime.
Cross-SDK contract
The descriptor, heartbeat, and negotiation wire are pinned as golden JSON fixtures that the NestJS, Adonis, and Python SDKs each produce and parse byte-identically in a conformance test. That is what keeps polyglot negotiation from rotting — see Cross-ecosystem interop.
Node worker
Author and run a store-less Node worker with @dudousxd/durable-worker — the JS counterpart to the Python client. Register @Step/@Workflow bodies on a DurableWorkerRuntime and drive them with runRedisWorker, with fixed or adaptive concurrency.
Cross-ecosystem interop
Run NestJS, Adonis, and Python workers on one durable control plane. The BullMQ transport is the shared wire, so a step dispatched by a NestJS engine can execute on a Python or Adonis worker and flow back.