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).

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)

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.