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_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 # output artifact id on the source (or the root id)
target: str # consumer task id
target_input: str # 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="clone",
source_output="slice",
target="gather",
target_input="results",
transfer=False, # order gather after clone, 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.
The declarative fan-out / fan-in (map) construct is built on ordering-only edges.
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.
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.
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).
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.
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 task computes how many clones it needs, then commits them and their
wiring in a single expand(...).
Registering Custom Workflows
To register workflow plugins, expose them through:
[project.entry-points."horus.workflow"]
For more details, refer to the Auto-Registry documentation.