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
- Coordinate with the named holder (the typed ClaimConflictError.Assignee field)
- Wait for lease expiry and use bd reclaim to recover an abandoned claim
- Pick a different issue from bd ready
- 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
- Never run unclaim+claim to steal a live claim — coordinate with the holder
- For abandoned claims, wait for lease expiry then use bd reclaim
- Assign unique actors per agent so conflicts are attributable
- In batch workers, treat ErrAlreadyClaimed as 'skip and continue'
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
- ErrAlreadyClaimed
- failed to get current claim state: %w
- ErrNotClaimable
- get claimed issue: %w
- %w: %s is held by %q, expected %q
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/37f84d3223a5403e.
Report an issue: GitHub.