Skip to main content
Executors are the primary extension point for Aether task logic. Every leaf task in a workflow is dispatched to an executor — a small Go struct that implements three methods and returns a typed result. The engine schedules and coordinates; executors do the work. This strict separation means you can add new task types without touching the scheduling core, and you can swap, version, or distribute executors independently.

The Plugin interface

Every executor must implement executor.Plugin:
Type() is the string that appears in the workflow JSON under executor.type. It must be unique across all registered plugins. Schema() is called once at registration time and cached — its result is stored in the executor registry and optionally persisted to the schema store. Execute() is called for every task dispatch.

ExecuteRequest

The engine bundles everything an executor needs into a single ExecuteRequest. Executors are self-contained — they never call back into the store.
string
Unique ID of this task run. Use it as a correlation key in logs and traces.
string
ID of the parent workflow run. Useful when an executor needs to emit metrics scoped to a run.
string
The task’s name within its DAG scope, as declared in the workflow document.
string
The name of the referenced template. Equals TaskName for inline tasks.
*model.Inputs
Resolved input parameters after interpolation and expression evaluation. May be nil when the task declares no inputs.
*model.Resources
CPU, memory, and GPU requirements declared on the task. May be nil.
string
Human-readable duration string (e.g. "30m", "2h"). Empty when the task has no deadline. Pass this to subprocesses or remote API calls so they can enforce their own deadline independently of the Go context.
int
Number of retries already consumed. 0 means this is the first attempt; 1 means the first retry.

ExecOutputs

Executors return *model.ExecOutputs:
Code must be one of the ExecCode constants. The engine maps this code to a Phase value — executors never write Phase directly.

ExecCode values

PhaseSkipped and PhaseCancelled are set exclusively by the engine and are never valid return codes for an executor.

Declaring the executor schema

Use executor.SchemaOf to derive an ExecutorSchema from Go struct types. This approach reflects struct field names and json/desc tags to populate the schema automatically — no hand-written field name strings.
  • C — the config struct that represents the executor’s accepted inputs.
  • O — the output struct that represents the executor’s produced parameters. Use executor.DynamicOutputs when outputs are determined at runtime.
Example:
For executors whose outputs depend on runtime inputs, use DynamicOutputs:

Type-safe output construction with OutputFrom

executor.OutputFrom converts a flat output struct to *model.ExecOutputs by reflecting its fields. The json tag on each field becomes the Parameter.Name.
Rules:
  • Input must be a flat struct — no embedded (anonymous) fields, no nested structs.
  • Fields tagged json:"-" are skipped.
  • All field values are JSON-serialised.

Reading inputs with BindInputs

executor.BindInputs is the counterpart to OutputFrom. It maps model.Inputs.Parameters into a destination struct by matching each parameter’s Name against the json tag of the corresponding field.

The executor Registry

executor.Registry manages registered plugins, routing dispatched tasks to the correct implementation.
Register returns an error if the type is already registered. It also caches the plugin’s Schema() result. RegisterSchema registers a schema without a plugin instance — used by distributed brokers to propagate remote worker schemas to the master, enabling schema-aware validation even when executors run on remote machines.

Engine options

WithExecutor

Registers a single executor plugin. Call multiple times to register multiple types. Duplicate type names are silently ignored at option-application time; duplicates are caught at workflow validation.

WithExecutorRegistry

Accepts a pre-built *executor.Registry. Use this when you share a registry between the engine and a broker, avoiding double-registration.

The fat-assignment principle

ExecuteRequest carries every piece of information the executor needs. Workers executing tasks never call back into the store. This makes executors:
  • Independently deployable — a remote worker process needs no store connection.
  • Easily testable — call Execute with a hand-crafted ExecuteRequest in any unit test.
  • Topology-agnostic — local goroutine, separate process, or remote machine: the executor code is identical.

Triggering suspension

An executor can pause a task mid-execution and wait for an external signal by returning ExecCodeSuspended:
When the external event arrives, call engine.Resume(ctx, taskRunID, additionalInputs). The engine merges the new inputs on top of the original resolved inputs (last-writer-wins) and re-dispatches the task.

Complete example: EchoExecutor

The playground’s EchoExecutor is a working reference implementation. It echoes inputs as outputs and supports three control parameters:
Control parameters in the echo executor’s workflow JSON:

Step-by-step: implementing a minimal executor

1

Define config and output structs

Create flat structs with json and desc tags for automatic schema derivation.
2

Implement the Plugin interface

Embed your structs into SchemaOf and use BindInputs / OutputFrom for type-safe I/O.
3

Register with the engine

Pass the executor to aether.New via WithExecutor.
4

Reference in workflow JSON

Set executor.type to the string returned by Type().