Aviary
Cluster

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 the descriptorHash changes.

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:

OutcomeMeaningDispatch
Compatibleprotocol ranges overlap + required capabilities presentdispatch freely
Degradedranges overlap but an optional capability is missingdispatch, but route capability-requiring work only to capable workers; soft warning
Incompatibleno protocol-range overlapdo 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:

supportedworkflowsmeansdo
false[]no transport here can introspect — nobody askedconfigure a transport
true[]the fleet was asked and nothing is livestart a worker
truepopulatedhere 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.

On this page