gastownhall/beads · error

UnclaimIfAssignee: %w

Error message

UnclaimIfAssignee: %w

What it means

Wrapped error from UnclaimIfAssignee: issueRepo.UnclaimIssueIfAssignee failed and is prefixed with "UnclaimIfAssignee: ". Because this is the compare-and-swap path, a very common cause is that the issue is no longer assigned to expectedAssignee (the CAS condition failed), in addition to ordinary storage failures.

Source

Thrown at internal/storage/domain/issue.go:1901

		return fmt.Errorf("Unclaim: %w", err)
	}
	return nil
}

// UnclaimIfAssignee is the compare-and-swap release: it clears the claim only
// while the issue is still assigned to expectedAssignee, and otherwise returns
// storage.ErrAssigneeMismatch having written nothing. It is the conditional
// twin of Unclaim and runs the SAME transition (assignee cleared, status
// reopened, started_at cleared, lease dropped, row_lock rewritten, "unclaimed"
// event recorded) because both reach the one classic implementation in
// issueops — which is what makes `bd unclaim --if-assignee` behave identically
// on the proxied-server and embedded backends.
func (u *issueUseCaseImpl) UnclaimIfAssignee(ctx context.Context, id, actor, expectedAssignee string) error {
	if id == "" {
		return fmt.Errorf("UnclaimIfAssignee: id must not be empty")
	}
	if err := u.issueRepo.UnclaimIssueIfAssignee(ctx, id, actor, expectedAssignee); err != nil {
		return fmt.Errorf("UnclaimIfAssignee: %w", err)
	}
	return nil
}

// Heartbeat refreshes the lease on an issue actor holds in_progress. The
// write touches ONLY the ephemeral leases table (bd-lrgn1), so the caller
// must run it under uow.RunTxEphemeral's no-Dolt-commit form — a heartbeat
// mints no Dolt commit and no history in any mode (bd-aq0ql).
func (u *issueUseCaseImpl) Heartbeat(ctx context.Context, id, actor string) error {
	if id == "" {
		return fmt.Errorf("Heartbeat: id must not be empty")
	}
	if err := u.issueRepo.HeartbeatIssue(ctx, id, actor); err != nil {
		return fmt.Errorf("Heartbeat: %w", err)
	}
	return nil
}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Unwrap the error and check for an assignee-mismatch/conflict cause
  2. Re-fetch the issue, refresh expectedAssignee, and retry the CAS
  3. Fall back to plain Unclaim with force if you intentionally want to override
  4. Check DB connectivity if the wrapped error is a storage failure

Example fix

// before
if err := uc.UnclaimIfAssignee(ctx, id, actor, expected); err != nil { return err }
// after
if err := uc.UnclaimIfAssignee(ctx, id, actor, expected); err != nil {
	if isAssigneeConflict(err) {
		issue, _ := uc.Show(ctx, id)
		expected = issue.Assignee
		return uc.UnclaimIfAssignee(ctx, id, actor, expected)
	}
	return err
}
Defensive patterns

Strategy: retry

Validate before calling

issue, err := uc.Show(ctx, id)
if err != nil || issue == nil || issue.Assignee != expectedAssignee {
	return fmt.Errorf("issue %s no longer assigned to %s; refresh before CAS unclaim", id, expectedAssignee)
}

Try / catch

err := uc.UnclaimIfAssignee(ctx, id, actor, expected)
if err != nil {
	if isAssigneeConflict(err) {
		time.Sleep(backoff)
		return uc.UnclaimIfAssignee(ctx, id, actor, refetchAssignee(ctx, id))
	}
	return err
}

Prevention

When it happens

Trigger: Calling UnclaimIfAssignee(ctx, id, actor, expectedAssignee) when the repo CAS fails: assignee changed since read, issue deleted, concurrent writer, or database error.

Common situations: Multi-agent workflows where another actor reassigned or unclaimed the issue between read and release; stale expectedAssignee values cached from an earlier Show call.

Related errors


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