windmill-labs/windmill · error

Cycle detected: adding edge from '${sourceId}' to '${targetI

Error message

Cycle detected: adding edge from '${sourceId}' to '${targetId}' would create a cycle.

What it means

addEdge runs a DFS cycle detection before inserting an edge; adding the requested source→target edge would make the flow graph cyclic, which React Flow / flow semantics do not support. The edge is rejected instead of silently creating a loop.

Source

Thrown at frontend/src/lib/components/graph/graphBuilder.svelte.ts:582

				disableMoveIds?: string[]
			}
		) {
			parents[targetId] = [...(parents[targetId] ?? []), sourceId]

			let index: number
			if (options?.currentItems) {
				index = findInsertIndexByNodeId(options.currentItems, targetId)
			} else {
				const mods = options?.subModules ?? modules
				const found = mods?.findIndex((m) => m.id === targetId) ?? -1
				index = found >= 0 ? found : (mods?.length ?? 0)
			}

			const visited = new Set<string>()
			const recStack = new Set<string>()

			if (detectCycle(sourceId, visited, recStack)) {
				throw new Error(
					`Cycle detected: adding edge from '${sourceId}' to '${targetId}' would create a cycle.`
				)
			}

			edges.push({
				id: options?.customId || `edge:${sourceId}->${targetId}`,
				source: sourceId,
				target: targetId,
				type: options?.type ?? 'edge',
				data: {
					sourceId,
					targetId,
					branch,
					eventHandlers,
					simplifiedTriggerView: simplifiableFlow?.simplifiedFlow,
					disableMoveIds: options?.disableMoveIds,
					enableTrigger: sourceId === 'Input',
					index,

View on GitHub (pinned to e474e8803c)

Solutions

  1. Remove the edge that loops back to an ancestor step; flows are DAGs.
  2. Restructure the logic with a loop/iterator construct (foreach step) instead of a graph cycle.
  3. If the edge is intentional only for visualization, give it a distinct handling path rather than pushing it as a real edge.
  4. Audit the code that generates edges so dependency order (topological) is respected.

Example fix

// before
addEdge('step3', 'step1') // creates cycle
// after
// model iteration with a foreach step containing step1..step3, no back edge
Defensive patterns

Strategy: validation

Validate before calling

function wouldCreateCycle(edges, source, target) {
  const adj = new Map(); edges.forEach(e => adj.set(e.source, [...(adj.get(e.source)||[]), e.target]))
  const stack = [target]; const seen = new Set()
  while (stack.length) { const n = stack.pop(); if (n === source) return true; if (seen.has(n)) continue; seen.add(n); stack.push(...(adj.get(n)||[])) }
  return false
}

Type guard

function isDagAddition(edges, source, target) { return !wouldCreateCycle(edges, source, target) }

Try / catch

try { addEdge(sourceId, targetId) } catch (e) { if (String(e.message).startsWith('Cycle detected')) toast('Flows cannot contain loops; use a foreach step'); else throw e }

Prevention

When it happens

Trigger: Calling addEdge (directly or via processModules) where targetId is an ancestor of sourceId in the existing edge set — e.g. wiring a later step back to an earlier one, or branch child edges that loop back to the parent.

Common situations: Hand-editing flow edges to model loops/branches back-edges; a bug in custom flow generation that emits both forward and backward dependency edges; dragging an edge in the editor from a child step to an ancestor.

Related errors


AI-assisted analysis of windmill-labs/windmill@e474e8803c (2026-09-03). Data as JSON: /api/errors/54e699185d9c6b8d. Report an issue: GitHub.