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
- Read the cycle path from the error and remove/reverse one of the listed edges so the chain no longer loops.
- Run 'bd dep cycles' as the message suggests to inspect and clean up existing cycles before retrying.
- 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.
- 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
- Run 'bd dep cycles' before large dependency imports and clean existing cycles first.
- Pre-validate each new edge for reachability against the current graph in tooling that adds edges programmatically.
- When two issues mutually block each other, use one directional edge plus a related link instead of both directions.
- When batch-adding, sort pairs and detect the first offending pair by adding one at a time.
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
- ExternalDoltConfig: TLSKey set without TLSCert
- no store is open for this workspace
- no absolute native user directory is available
- ExternalDoltConfig: set either Socket OR (Host, Port), not b
- ExternalDoltConfig: must set Socket or (Host, Port)
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/1f1a48add211251b.
Report an issue: GitHub.