gastownhall/beads · error · domain.CycleError

dependency cycle would be created: %s (no edges added; run '

Error message

dependency cycle would be created: %s (no edges added; run 'bd dep cycles' for analysis)

What it means

The final in-transaction cycle check found that asserting the requested dependency edges would create a scheduling cycle, so ExecuteAddDependencies aborts and no edges are added. domain.NewCycleError produces a typed error carrying the cycle path, and the message suggests running 'bd dep cycles' to analyze existing cycles. This is a deliberate domain rule: the dependency graph must remain acyclic.

Source

Thrown at internal/storage/issueops/dependency_editor.go:231

// already prints.
func checkAddedEdgesForCycles(ctx context.Context, tx *sql.Tx, edges []publicops.DependencyEdge) error {
	var pairs [][2]string
	for _, edge := range edges {
		if !types.IsSchedulingEdge(edge.Type) {
			continue
		}
		pairs = append(pairs, [2]string{edge.IssueID, edge.DependsOnID})
	}
	if len(pairs) == 0 {
		return nil
	}
	graph := make(map[string][]string)
	if err := AppendSchedulingGraphInTx(ctx, tx, cycleDetectionTables(), graph); err != nil {
		return fmt.Errorf("final cycle check failed (no edges added): %w", err)
	}
	if cyclePath := CycleThroughEdgesInGraph(graph, pairs); cyclePath != "" {
		return domain.NewCycleError("dependency cycle would be created: %s (no edges added; run 'bd dep cycles' for analysis)", cyclePath)
	}
	return nil
}

// ExecuteRemoveDependency removes one edge in tx and reports the durable
// tables changed.
//
// A missing edge reports Removed false and NO changed tables, which is how the
// callers spell "commit nothing": removing an edge that was never there leaves
// the graph it already had, and a history entry for it would be a commit with
// nothing in it.
func ExecuteRemoveDependency(ctx context.Context, tx *sql.Tx, request publicops.RemoveDependencyRequest) (publicops.RemoveDependencyResult, ChangedTables, error) {
	// Routing is READ here rather than pinned, unlike the add. A removal
	// cannot put an edge anywhere, so pinning it would only mean failing to
	// remove an edge the caller named — and the staging has to name the tables
	// the delete actually touched or the commit sweeps rows it never wrote
	// (GH#2455). ChangedTables drops the wisp tables itself.
	sourceIsWisp := IsActiveWispInTx(ctx, tx, request.IssueID)
	_, _, eventTable, depTable := WispTableRouting(sourceIsWisp)

View on GitHub (pinned to 71377f2769)

Solutions

  1. Read the cycle path from the error and remove/reverse one of the listed edges so the chain no longer loops.
  2. Run 'bd dep cycles' as the message suggests to inspect and clean up existing cycles before retrying.
  3. If a batch add fails, split it and add pairs one at a time (or pre-validate the batch offline against the current graph) to isolate the offending pair.
  4. If the two issues should truly block each other, model it with a different mechanism (e.g. a single directional edge plus a related/discovered link) instead of mutual depends-on edges.

Example fix

// before: closes a cycle
ExecuteAddDependencies(ctx, req{pairs: [{A, B}]}) // B already transitively depends on A

// after: assert the opposite direction or drop the edge
// bd dep remove B --depends-on A   (or skip the pair)
ExecuteAddDependencies(ctx, req{pairs: [{B, A}]})
Defensive patterns

Strategy: try-catch

Validate before calling

// pre-flight outside the tx: check the pair doesn't close a loop
// bd dep cycles  (CLI) or load the graph and test reachability
func wouldCycle(graph map[string][]string, from, to string) bool {
    seen := map[string]bool{}
    var dfs func(string) bool
    dfs = func(n string) bool {
        if n == from { return true }
        if seen[n] { return false }
        seen[n] = true
        for _, m := range graph[n] { if dfs(m) { return true } }
        return false
    }
    return dfs(to)
}

Type guard

func isCycleError(err error) bool {
    var ce *domain.CycleError
    return errors.As(err, &ce)
}

Try / catch

err := editor.ExecuteAddDependencies(ctx, req)
var ce *domain.CycleError
if errors.As(err, &ce) {
    // surface ce's cycle path to the user; suggest 'bd dep cycles'
    return fmt.Errorf("cannot add dependency: %v", ce)
}
if err != nil { return err }

Prevention

When it happens

Trigger: Calling ExecuteAddDependencies (or the runEndGate path) where CycleThroughEdgesInGraph finds a path through the existing graph plus the new pairs that loops back to a node — e.g. adding bd-1 depends-on bd-2 when bd-2 already (transitively) depends on bd-1.

Common situations: Bulk-adding dependencies where one pair in the batch closes a loop with another pair; concurrent agents each adding opposite-direction edges; re-adding edges after a prior partial import created a near-cycle; users manually wiring 'blocked by' in both directions.

Related errors


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/1f1a48add211251b. Report an issue: GitHub.