Workflow
Workflow System
Workflows orchestrate a set of tasks connected as a directed acyclic graph (DAG).
Tasks are not executed in definition order. Instead, Horus derives the execution order from the workflow's explicit edges: each edge wires one task's output to another task's input, declaring that the producer must run before the consumer. The workflow author declares the tasks and the edges between them; the runtime works out when each task runs (see DAG planning).
Edges are the sole source of truth for the DAG. Earlier versions inferred dependencies by matching input/output artifact
ids. That implicit matching is gone: two tasks that happen to share an artifactidare not linked unless an edge connects them, and a workflow with no edges treats its tasks as independent nodes with no ordering.
Core Concept
Every workflow must implement all three abstract methods:
@classmethod
def from_yaml(cls, path: str | Path) -> Self:
...
async def _run(self, trigger_id: str) -> None:
...
async def _reset(self) -> None:
...Contract
from_yaml(): load and construct a workflow from a YAML file, recording the file it loaded assource_path_run(trigger_id): workflow-specific execution logic; do not mutatestatushere_reset(): async: subclass-specific reset logic; do not mutatestatushere.reset()/_reset()areasync; callers mustawait.run(trigger_id)is the publicfinalentry point and runsWorkflowMiddlewarekind: stris the registry discriminator
Base Workflow
All workflows inherit from BaseWorkflow:
class BaseWorkflow(AutoRegistry, entry_point="workflow"):
registry_key: ClassVar[str] = "kind"
kind: str
kind_name: ClassVar[str] = "Workflow"
kind_description: ClassVar[str] = _("Base workflow")
name: str
tasks: list[BaseTask] = Field(default_factory=list)
artifacts: list[BaseArtifact] = Field(default_factory=list)
edges: list[WorkflowEdge] = Field(default_factory=list)
orchestrator_target: BaseTarget | None = None
capacity: dict[str, ResourceCapacity] | None = None
failure_policy: Literal["fail_fast", "continue"] = "fail_fast"
max_concurrency: int | None = None
status: WorkflowStatus = WorkflowStatus.IDLE
@classmethod
@abstractmethod
def from_yaml(cls, path: str | Path) -> Self:
"""Load and construct a workflow from a YAML file."""
async def transfer_artifacts(
self,
task: BaseTask,
source_map: dict[tuple[str, str], EdgeSource] | None = None,
) -> None:
"""Transfer input artifacts of task to its target before dispatch."""
...
@final
async def run(self, trigger_id: str) -> None:
"""Drives status transitions: RUNNING → COMPLETED | CANCELED | FAILED."""
...
@abstractmethod
async def _run(self, trigger_id: str) -> None:
"""Workflow-specific execution logic. Do not set self.status here."""
@final
async def reset(self) -> None:
"""Reset status to IDLE and delegate to _reset()."""
...
@abstractmethod
async def _reset(self) -> None:
"""Subclass-specific reset logic. Do not set self.status here."""Subclasses must implement from_yaml(), _run(), and _reset().
run(trigger_id) wraps _run(trigger_id) in
WorkflowMiddleware.call_with_middleware(...) and owns the workflow status
transitions.
tasks
tasks is a list of BaseTask instances. Each task carries its own id
(see Task IDs); the runtime keys tasks by that id internally, so
the list order is not what determines execution order: the DAG does.
artifacts
artifacts is a list of root artifacts: artifacts that exist before the
workflow runs and are not produced by any task (for example a dataset file or a
user upload). A task consumes a root artifact through an edge whose source
is the root (using the artifact-<rootId> source convention, see
edges).
Root artifacts have no producer task, so they create no dependency during DAG
planning, and the transfer layer treats them as coming from
orchestrator_target.
Root artifact ids must be unique among the root artifacts.
edges
edges is a list of WorkflowEdge connections, and the single source of
truth for both the DAG and the artifact transfer sources. Each edge wires one
producer output to one consumer input:
class WorkflowEdge(BaseModel):
source: str # producer task id, or "artifact-<rootId>" for a root
source_output: str | None # output artifact id on the source (or the root id)
target: str # consumer task id
target_input: str | None # input artifact id on the consumer task
transfer: bool = True # False for an ordering-only edge (see below)A source that names a task declares a dependency: the source task must
complete before the target task. A source of the form artifact-<rootId>
is a root source: it feeds a user-provided root artifact into the input and
adds no dependency.
Edges are validated at construction time (see Edge validation), so a typo cannot silently drop a dependency or misroute a transfer.
from horus_runtime.core.workflow.edge import WorkflowEdge
WorkflowEdge(
source="parse", # task "parse" produces ...
source_output="parsed", # ... its output artifact "parsed" ...
target="dock", # ... which feeds task "dock" ...
target_input="ligand", # ... as its input "ligand".
)Because the connection is explicit, the producer output and the consumer input
may carry different ids (parsed to ligand); the old requirement that
they share an id no longer applies.
Ordering-only edges (transfer=False)
By default an edge does two things at once: it orders the two tasks (source
before target) and it routes the source artifact into the target input. Set
transfer=False to keep only the ordering and drop the routing:
WorkflowEdge(
source="worker",
source_output="part",
target="assemble",
target_input="kit",
transfer=False, # order assemble after worker, move no bytes
)An ordering-only edge still validates its endpoints and still makes target
depend on source in the DAG, but it contributes nothing to the transfer
source map, so the target input keeps whatever path it already has. This is what
lets many producers order-gate a single consumer whose real data input is not
fed by any one upstream edge (for example a populated folder that several tasks
each write a slice into). Because such an edge routes no data, the
one edge per (target, target_input) rule does not apply to
it: any number of transfer=False edges, plus at most one transfer=True edge,
may feed the same input.
Edges that name no artifacts
transfer=False still requires source_output and target_input to name
declared artifacts, so it cannot order a task that declares none. Both ids are
optional for exactly that case:
WorkflowEdge(source="prep", target="cleanup") # prep before cleanup, nothing namedNaming neither id forces transfer=False: an edge with nothing on either
end has nothing to carry. transfer therefore remains the single question every
consumer asks ("does this edge supply a transfer source?"), and no caller has to
re-derive the answer from the ids. Since such an edge feeds no input, the
one edge per (target, target_input) rule cannot apply to
it either: any number may point at the same task.
Name both ids or neither. Naming exactly one raises IncompleteEdgeError at
construction rather than silently degrading a data edge into an ordering-only
one. The source must resolve to a real task — a root artifact
(artifact-<rootId>) is not a task and so has nothing to order against.
Kind metadata
Workflows may declare kind_name and kind_description ClassVars to make
registry entries more discoverable. Use your plugin's translator (created via
make_translator and commonly aliased as _(...)) for translatable
descriptions.
capacity
capacity gates concurrency against finite hardware so a large fan-out never
oversubscribes a machine. It is a dict[str, ResourceCapacity] keyed by
target.location_id, so targets that share a machine share one pool
(ResourceCapacity, PlacementManager, and InsufficientCapacityError live in
horus_runtime.core.placement).
Each capacity dimension left None is unconstrained; only declared dimensions
are gated. When a task becomes ready the placement manager waits on an
asyncio.Condition until the task's declared ResourceRequest dimensions fit,
subtracts them, and adds them back on completion. Placement is opt-in and
identical to the old behaviour when it does not apply: no capacity declared, or
resources=None, or an unknown location, all acquire immediately, with
max_concurrency still enforced on top. A request that exceeds a location's
total capacity raises InsufficientCapacityError immediately instead of
blocking the ready-set loop.
Capacity accounting lives in a single workflow-level placement manager,
exposed as the placement property. The scheduler and any composite task that
dispatches sub-tasks of its own (a horus_map awaiting its clones, for
instance) share that one manager, so the accounting for a given location stays
global across the whole run instead of resetting per dispatcher.
failure_policy
failure_policy decides how the scheduler reacts to a task failure:
fail_fast(default): the first task to raise cancels every other in-flight task, awaits their unwind, and re-raises, sorun()transitions the workflow toFAILED. This is the historical behaviour.continue: a failed task no longer aborts the run. The scheduler marks the failure's descendants as blocked so they are never dispatched, and lets every other branch of the DAG run to completion. Once nothing more can become ready, if anything failed it raisesWorkflowExecutionErrornaming every failed task, so the workflow still endsFAILED.
Either policy always ends a run with failures in FAILED; the policy only
controls how much of the DAG runs first. Each newly blocked task emits a
HorusTaskEvent naming the upstream failure, so the event bus and TUI have
something to show without a new task status.
orchestrator_target
orchestrator_target identifies the machine running the workflow itself. It is
used as the transfer source for root input artifacts: those not produced by
any upstream task in the workflow.
Leaving it as None is valid for purely local workflows. The transfer layer
raises OrchestratorTargetNotSetError only when a task actually needs a root
artifact from a source that has not been configured.
transfer_artifacts()
Called by _run() implementations before each task.target.dispatch(task). It:
- resolves, for each input, the source from the workflow
edges: a task source yields the producer task's target and its output artifact; a root-source edge yields the workflow'sorchestrator_target - looks up the registered
BaseTransferStrategyfor the(source, destination)pair - transfers a copy of the producing artifact (it carries the
idthe data is stored under) and then repoints the consumer input'spathat the materialized result, so the input keeps its ownidfor templating
_run() builds the edge source map once and passes it to every
transfer_artifacts() call via the optional source_map argument; omitting it
rebuilds the map on demand.
See Transfer Strategy for details.
source_path
source_path is the file from_yaml() loaded, resolved to an absolute path:
wf = HorusWorkflow.from_yaml("my-pipeline/pipeline.yaml")
wf.source_path # PosixPath('/abs/my-pipeline/pipeline.yaml')It is None for a workflow built directly in Python or restored from a
snapshot, neither of which has a file behind it. Deriving it from
_base_directory would only work when the file happened to be named
workflow.yaml, and the CLI accepts any filename (horus run pipeline.yaml),
so the loader keeps the file path itself rather than making callers guess it.
Like _base_directory, it is runtime-only state, not a model field, and never
serialized: a snapshot's meaning is unchanged, and a workflow round-tripped
through one has source_path is None.
Built-in Workflow
HorusWorkflow: builds the DAG from the workflowedges, computes an execution plan from the trigger, and runs the plan with a concurrent ready-set scheduler (see Concurrent scheduling), skipping tasks whose outputs already exist whentask.skip_if_completeisTrue
HorusWorkflow sets orchestrator_target = LocalTarget() by default. For each
task it becomes eligible to run it calls transfer_artifacts() before
task.target.dispatch(task).
Concurrent scheduling
HorusWorkflow does not execute the plan serially. It runs a ready-set
scheduler: on every iteration it recomputes which tasks have all their
dependencies satisfied and dispatches all of them at once, then reacts to each
completion to unblock newly-ready downstream tasks. Independent branches of the
DAG therefore run concurrently, and the run reacts to actual completion order
rather than a fixed topological sort.
max_concurrency (a BaseWorkflow field, None = unbounded) caps the number
of genuinely concurrent dispatches. A single-slot target reused across several
placements does not serialize them: when the exact target instance a ready task
declares is already busy, the scheduler hands out an idle target.model_copy()
clone instead of blocking. Cloning is a valid extra slot because targets of the
same class share a location_id (the same filesystem).
Fail-fast is preserved: the first task to raise cancels every task still in
flight, awaits their unwind, and re-raises, so run() still marks the workflow
FAILED. (A workflow can opt out of fail-fast; see failure_policy.)
Example
run() requires a trigger_id: the id of the task that initiates the run.
The runtime plans the DAG around that task (see DAG planning).
import asyncio
from horus_builtin.workflow.horus_workflow import HorusWorkflow
from horus_runtime.core.workflow.edge import WorkflowEdge
wf = HorusWorkflow(
name="example",
tasks=[prepare, final_step],
edges=[
WorkflowEdge(
source="prepare",
source_output="dataset",
target="final_step",
target_input="input",
),
],
)
# Run the workflow, triggered by the task whose id is "final_step".
# The edge pulls "prepare" in as an ancestor, so it runs first.
asyncio.run(wf.run(trigger_id="final_step"))Task IDs
Each task owns an explicit id. Task IDs must be unique within a workflow,
they are the handles used for dependency resolution, for selecting a trigger,
and for keying tasks internally.
Uniqueness is enforced at construction time by a model validator: a workflow
with two tasks sharing an id raises TaskIdsAreNotUniqueError.
Artifact id uniqueness is enforced only where edge resolution needs it
(ArtifactIdsAreNotUniqueError otherwise):
- output
ids must be unique within each task; - input
ids must be unique within each task; - root artifact
ids must be unique among the root artifacts.
Output ids may now repeat across tasks: because edges resolve on
(task id, output id) and task ids are unique, the same reusable task (sharing
a definition_id) can be placed in a workflow more than once, each placement
keeping its own unique task id.
DAG planning
A workflow is a directed acyclic graph where nodes are tasks and edges
are the workflow's WorkflowEdge connections. The graph is derived entirely
from edges — never from artifact-id matching.
How dependencies are derived
- Edges. Each edge whose
sourceis a task adds a dependency: thetargettask depends on thesourcetask. Edges whosesourceis a root artifact (artifact-<rootId>) add no dependency — root inputs are graph entry points sourced fromorchestrator_target. - No edges, no ordering. A workflow with no edges has fully independent
tasks. Sharing an artifact
idacross two tasks does not link them. - Ordering. The resulting graph is sorted topologically (Kahn's algorithm, with ties broken deterministically by id) to produce the execution order.
If the edges form a cycle, planning fails with CyclicDependencyError.
Edge validation
Because edges are the only thing wiring the DAG, they are validated at workflow construction time so a typo cannot silently drop a dependency or misroute a transfer. A workflow raises:
UnknownEdgeEndpointErrorif an edge references a missing task, atarget_inputthat the target task does not declare, asource_outputthat the source task does not produce, or a rootsource_outputthat no root artifact declares;DuplicateEdgeTargetErrorif two transferring edges feed the same(target, target_input): each consumer input may be fed by at most onetransfer=Trueedge. Ordering-only (transfer=False) edges are exempt, since they route no data (see Ordering-only edges).add_edgeapplies this rule exactly as workflow construction does;IncompleteEdgeErrorif an edge names one endpoint artifact id but not the other. An edge names both (it may carry data) or neither (it only orders its two tasks — see Edges that name no artifacts); anything between is a typo that would otherwise silently drop a transfer.
An edge that names no artifacts is still validated: source and target must
be real tasks. Only the artifact-id checks are skipped, because there are none.
Trigger IDs
Every run is initiated by a trigger: the id of one task in the workflow,
passed to run(trigger_id=...). The trigger scopes which tasks run, a workflow
rarely needs to execute its entire graph on every run.
Given a trigger, the execution plan is the trigger task plus:
- its ancestors: every upstream task needed to produce the trigger's inputs, walked transitively; and
- its descendants: every downstream task that (directly or transitively) consumes the trigger's outputs.
Unrelated branches of the graph are excluded entirely. Within that scope, tasks
still run in topological order, and any ancestor whose outputs already exist is
skipped at run time via is_complete() (when skip_if_complete is True), so
upstream work is not redundantly recomputed.
Passing a trigger_id that does not correspond to a task in the workflow raises
UnknownTaskError (from execution_plan). HorusWorkflow._run likewise rejects
an unknown trigger before planning.
Mutating the DAG at runtime
A workflow's graph is normally fixed at construction time, but it can also be
grown while the workflow is running. Because the scheduler recomputes the
ready set from self.tasks and self.edges on every iteration, tasks and edges
added mid-run are picked up automatically, with no scheduler restart.
BaseWorkflow exposes four mutators:
add_artifact(artifact): register a standalone root artifact. Its root id must be unique among the existing roots.add_task(task): append a task. Its id must be unique, its input and output ids unique within the task, and it is anchored to the run directory exactly like a construction-time task.add_edge(edge): append an edge. Its endpoints must resolve, at most one transferring edge may feed a given(target, target_input), and the edge must not introduce a cycle.expand(tasks=[...], edges=[...], artifacts=[...]): a transactional batch. The whole batch is validated against the post-batch graph (unique ids, edge resolution, no cycle) and committed all-or-nothing. Edges in the batch may reference tasks or artifacts added in the same batch. A batch also supersedes what it re-derives — see below.
expand() supersedes what a batch re-derives
add_task rejects a duplicate id. expand does not: a task in the batch whose
id already exists replaces the existing one, together with every edge that
touches that id, and a root artifact in the batch replaces an existing root of
the same id.
That asymmetry is deliberate, and it is what makes an expanding construct
re-runnable. A horus_map task registers its clones through expand(...)
every time it actually runs, so a run resumed from a snapshot that already
carries those clones would otherwise collide with itself:
TaskIdsAreNotUniqueError: Multiple tasks share the same ID 'score[0]'The freshly derived task has to win regardless of which is "newer", because its paths are pinned under this run's root while the snapshot's copy still names the previous run's directory.
Stale edges are dropped by endpoint, not by equality: an expander re-emits every edge touching the tasks it owns, so any edge on a superseded id is one the batch is about to re-state.
Duplicates within a single batch remain an error —
TaskIdsAreNotUniqueError for two tasks sharing an id, and
ArtifactIdsAreNotUniqueError for two root artifacts.
Do not use expand() to update a task in place
Supersession replaces the task object and every edge touching it. Passing a
partially-built task with the id of a live one silently drops that task's
existing wiring. expand is for re-deriving a batch you own end to end.
Each mutator uses incremental validation that mirrors the construction-time
model validators (unique ids, endpoint resolution, cycle rejection) rather than
re-validating the whole model, and reuses the same typed errors:
TaskIdsAreNotUniqueError, ArtifactIdsAreNotUniqueError,
UnknownEdgeEndpointError, DuplicateEdgeTargetError, and
CyclicDependencyError.
Reaching the live workflow from a task
A running task reaches the workflow it belongs to through the run context:
from horus_runtime.context import HorusContext
wf = HorusContext.get_context().workflow
wf.expand(tasks=[...], edges=[...])The same workflow is also available as task.workflow. This is the seam the
built-in fan-out / fan-in (map) and loop constructs use to grow the graph while
it runs: a map commits all of its clones in a single expand(...), while a
loop injects the next iteration one step at a time.
Registering Custom Workflows
To register workflow plugins, expose them through:
[project.entry-points."horus.workflow"]For more details, refer to the Auto-Registry documentation.