gastownhall/beads · warning · ClaimConflictError

ErrAlreadyClaimed

ErrAlreadyClaimed

Error message

%w: already assigned to %q — coordinate with the holder; if their claim is abandoned (crashed agent), lease expiry will surface it for bd reclaim

What it means

Refusal raised when the issue is currently open but assigned to a different actor: anti-steal protection (GH-3570) prevents reassignment. The message wraps storage.ErrAlreadyClaimed with the holder's name and steers toward coordination/lease expiry (bd reclaim) rather than naming an unclaim command. It is delivered inside a *publicops.ClaimConflictError whose typed Assignee field is the authoritative holder (the prose deliberately omits the parseable tail, bd-at6rc).

Solutions

  1. Coordinate with the named holder (the typed ClaimConflictError.Assignee field)
  2. Wait for lease expiry and use bd reclaim to recover an abandoned claim
  3. Pick a different issue from bd ready
  4. Handle errors.Is(err, storage.ErrAlreadyClaimed) in batch loops and continue

Example fix

// before
res, err := ClaimIssueInTx(ctx, tx, id, actor)
// after
res, err := ClaimIssueInTx(ctx, tx, id, actor)
var conflict *publicops.ClaimConflictError
if errors.As(err, &conflict) && errors.Is(err, storage.ErrAlreadyClaimed) {
    log.Printf("%s held by %s — trying next", conflict.IssueID, conflict.Assignee)
    return nextReadyIssue()
}
Defensive patterns

Strategy: type-guard

Validate before calling

iss, _ := GetIssueInTx(ctx, tx, id)
if iss.Assignee != "" && iss.Assignee != actor && !poolAliases.Contains(iss.Assignee) {
    return fmt.Errorf("%s held by %s", id, iss.Assignee)
}

Type guard

func isHeldByOther(err error) (holder string, ok bool) {
    var c *publicops.ClaimConflictError
    if errors.As(err, &c) && errors.Is(err, storage.ErrAlreadyClaimed) {
        return c.Assignee, true
    }
    return "", false
}

Try / catch

var c *publicops.ClaimConflictError
if errors.As(err, &c) && errors.Is(err, storage.ErrAlreadyClaimed) {
    log.Printf("%s held by %s — picking next", c.IssueID, c.Assignee)
    return nextReadyIssue()
}

Prevention

When it happens

Trigger: ClaimIssueInTx where rowsAffected == 0, assignee is non-empty, actorMatches(assignee, actor) is false, and currentStatus == open — i.e. someone else already holds an open claim on this issue.

Common situations: Multiple agents/developers claiming from the same queue; a crashed agent left its claim on an open issue; stale local state where another node assigned the issue between your read and claim.

Related errors


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

Appendix: source

Thrown at internal/storage/issueops/claim.go:184

			// status rather than a misleading held-by-someone refusal. Checked
			// FIRST, so a pool alias never falls into the holder-steering copy.
			// Exact-string membership, same reason as assigneeOK's identical
			// term above: a pool alias is a literal config value, not a
			// respelled identity.
			case slices.Contains(pools, assignee):
				// refusal already names the status.
			case currentStatus == types.StatusOpen:
				// Do not name a release command here — not `bd unclaim`, not
				// `bd unclaim --force`. Refusal copy that names one gets
				// pattern-matched by batch agents into an unclaim+claim
				// steamroller of live claims (wy-yuclk). Point at the holder;
				// bd reclaim is safe to name because it only recovers claims
				// whose lease has already expired.
				//
				// This copy deliberately omits the parseable " by <assignee>"
				// tail, so ParseClaimConflict recovers the holder from the
				// typed field rather than the prose (bd-at6rc).
				refusal = fmt.Errorf("%w: already assigned to %q — coordinate with the holder; if their claim is abandoned (crashed agent), lease expiry will surface it for bd reclaim", storage.ErrAlreadyClaimed, assignee)
			default:
				refusal = fmt.Errorf("%w%s%s", storage.ErrAlreadyClaimed, storage.ClaimedByFragment, assignee)
			}
		}
		return nil, &publicops.ClaimConflictError{
			IssueID:  id,
			Assignee: assignee,
			Status:   currentStatus,
			Err:      refusal,
		}
	}

	// Grant the lease: what makes the claim recoverable — a worker that dies
	// stops heartbeating and bd reclaim later reverts the issue. Lease rows
	// live in the ephemeral leases table (no Dolt commit, node-local). Wisps
	// are never leased (they are ephemeral, not reclaimable work).
	if !isWisp {
		if err := UpsertLeaseInTx(ctx, tx, id, actor, now, leaseTTL(ctx)); err != nil {

View on GitHub (pinned to 71377f2769)