Does Hydra Detect Cycles?

Short answer: no, Hydra does not detect or prevent cycles, and it should not. A Hydra machine is a Mealy machine — a state machine, not a DAG. Cycles are a normal, correct part of the modelling vocabulary.

Every one of these is a legitimate machine Hydra models today:

Shape Example

Self-loop

sb.dontTransition(action) — stay put and re-emit a command (a poll, a heartbeat). See Building & Driving.

Retry back-edge

Charging ── PaymentDeclined ──▶ Charging — try the same step again with a fresh attempt.

Resubmit after reject

Approving ── Rejected ──▶ Drafting — send the work back for another round.

A cycle-detecting engine would reject these valid machines. Rejecting correct models is a worse failure than the problem it purports to solve.

Why the engine leaves cycles alone

  1. Cycles are correct modelling. Retries, polls, and rework loops are the whole point of a state machine. Forbidding them narrows Hydra to a strictly-linear pipeline — a much smaller tool.

  2. The transition graph is not statically knowable. A transition’s target is computed by a data-dependent lambdaS?.(E) → TransitionTo — not a static edge. The next state can depend on the current state’s data, so there is no fixed edge set to run a cycle check against. The DAG diagrams in these docs are hand-drawn documentation of one machine’s intended shape, not a graph the engine derives or enforces.

  3. The engine cannot spin. Hydra is a pure, reactive router. transition(event) and readTransitionAndNotifyListeners(…​) each fire once per external event and return — there is no internal loop that could run away. A machine "loops forever" only if something outside Hydra keeps feeding it events. That makes a runaway an orchestration concern, not an engine one.

Guarding a runaway workflow

If a workflow could legitimately cycle and you want a backstop against an unbounded run, put it in the dispatch loop, not the engine. The async orchestrator already persists a durable cursor per run — add a step counter to it and abort past a threshold:

val cursor = store.load(message.runId) ?: return true
if (cursor.steps >= MAX_STEPS) {                     // budget exhausted — stop the run
  store.markFailed(message.runId, "step budget exceeded")
  return true
}
val transition = hydra.readTransitionAndNotifyListeners(cursor.state, message.event)
val advanced = store.advance(message.runId, cursor.version, transition.toState, statusFor(...))
// advance(...) also bumps steps = steps + 1 alongside the version

The guard lives where the loop lives — in your store and your worker — so the engine stays a small, dependency-light pure function.

The word DAG in Async Orchestrators describes the shape of that specific order-fulfillment pipeline, which happens to be acyclic. It is not a constraint Hydra imposes — a cyclic machine drives through the exact same API.