gastownhall/beads · error

recompute is_blocked after add dependency %s -> %s: %w

Error message

recompute is_blocked after add dependency %s -> %s: %w

What it means

Wraps a failure from RecomputeIsBlockedInTxWithResult when a parent-child dependency is added inside addDependencyInTx. Parent-child adds are not monotonic (adding a closed child can unblock a waiter), so the full is_blocked state of affected issues/wisps must be recomputed; if that recompute fails the whole dependency-add transaction is aborted with this wrapped cause.

Source

Thrown at internal/storage/issueops/dependencies.go:346

		affectedIssues, affectedWisps, aerr = AffectedByDepChangeForWispInTx(ctx, tx, dep.IssueID, dep.DependsOnID, dep.Type)
	} else {
		affectedIssues, affectedWisps, aerr = AffectedByDepChangeInTx(ctx, tx, dep.IssueID, dep.DependsOnID, dep.Type)
	}
	if aerr != nil {
		return false, fmt.Errorf("affected by add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, aerr)
	}
	if dep.Type == types.DepBlocks || dep.Type == types.DepConditionalBlocks {
		if err := markDirectBlockingDependencySourceInTx(ctx, tx, dep.IssueID, srcIsWisp, dep.DependsOnID, kind, opts.PrecheckedTarget); err != nil {
			return false, fmt.Errorf("mark direct is_blocked after add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, err)
		}
		affectedIssues, affectedWisps = RemoveSourceFromAffected(dep.IssueID, srcIsWisp, affectedIssues, affectedWisps)
	}
	if dep.Type == types.DepParentChild {
		// Parent-child adds are not monotonic: adding an already-closed child can
		// satisfy an any-children waits-for gate and unblock the waiter.
		recomputed, err := RecomputeIsBlockedInTxWithResult(ctx, tx, affectedIssues, affectedWisps)
		if err != nil {
			return false, fmt.Errorf("recompute is_blocked after add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, err)
		}
		mergeRecomputeIsBlockedResult(recomputeResult, recomputed)
		// Snapshot only after all derived blocked-state maintenance has completed.
		return eventWritten, RecordDepEventInTx(ctx, tx, EventDepAdd, dep.IssueID, string(dep.Type), dep.DependsOnID, metadata, actor)
	}
	if err := MarkIsBlockedInTx(ctx, tx, affectedIssues, affectedWisps); err != nil {
		return false, fmt.Errorf("mark is_blocked after add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, err)
	}
	// Snapshot only after all derived blocked-state maintenance has completed.
	// The journal is never gated on opts.EmitEvent: a structurally-wired edge is
	// as real to a replaying consumer as one added by an explicit dep verb.
	return eventWritten, RecordDepEventInTx(ctx, tx, EventDepAdd, dep.IssueID, string(dep.Type), dep.DependsOnID, metadata, actor)
}

// RemoveSourceFromAffected drops the dep source from the affected-ID sets
// after a direct is_blocked mark, so the follow-up Mark/Recompute pass does
// not redo it. Shared with the domain/db dependency repository.
func RemoveSourceFromAffected(source string, srcIsWisp bool, issueIDs, wispIDs []string) ([]string, []string) {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Inspect the wrapped cause (%w) to find the underlying SQL failure and fix that first
  2. Verify all affectedIssues/affectedWisps rows exist in the issues/wisp tables before adding the edge
  3. Retry the operation once the database is reachable; the whole tx rolled back so no partial state persists
  4. Check for concurrent writers causing serialization/lock failures and retry with backoff

Example fix

// before
recomputed, err := RecomputeIsBlockedInTxWithResult(ctx, tx, affectedIssues, affectedWisps)
if err != nil {
	return false, fmt.Errorf("recompute is_blocked after add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, err)
}
// after: pre-validate targets exist so recompute cannot fail on dangling IDs
if err := ensureIssuesExistInTx(ctx, tx, affectedIssues); err != nil {
	return false, fmt.Errorf("precheck affected issues before add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, err)
}
recomputed, err := RecomputeIsBlockedInTxWithResult(ctx, tx, affectedIssues, affectedWisps)
if err != nil {
	return false, fmt.Errorf("recompute is_blocked after add dependency %s -> %s: %w", dep.IssueID, dep.DependsOnID, err)
}
Defensive patterns

Strategy: try-catch

Validate before calling

// pre-check that all affected issues/wisps exist before adding parent-child edge
for _, id := range append(affectedIssues, affectedWisps...) {
	if !exists(ctx, db, id) {
		return fmt.Errorf("affected issue %s missing; aborting dep add", id)
	}
}
if dep.IssueID == dep.DependsOnID {
	return errors.New("refusing self parent-child edge")
}

Type guard

func isRecomputeFailure(err error) bool {
	return err != nil && strings.Contains(err.Error(), "recompute is_blocked after add dependency")
}

Try / catch

err := store.AddDependencyInTx(ctx, tx, dep, opts)
var wrapped error
if err != nil && strings.HasPrefix(err.Error(), "recompute is_blocked") {
	// transient/state issue: rollback happened; inspect cause and retry
	wrapped = fmt.Errorf("retry dep add later: %w", err)
}

Prevention

When it happens

Trigger: Calling AddDependencyInTx or ApplyParentPatch with a Dependency whose Type is types.DepParentChild, where RecomputeIsBlockedInTxWithResult errors (underlying SQL failure in the cycle/state recomputation, lock contention, or a corrupted row for an affected issue/wisp).

Common situations: Database connectivity drops mid-transaction; Dolt transaction conflicts under concurrent parent-child edits; a dependency references an issue/wisp ID missing from its table so recompute queries fail.

Related errors


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