gastownhall/beads · warning · ClaimConflictError

ErrNotClaimable

ErrNotClaimable

Error message

%w%s%s

What it means

This is the structured claim refusal when the issue is not in a claimable status: the CAS affected 0 rows, the current status is not open (or an active custom status), and no other actor holds it. It wraps storage.ErrNotClaimable with the NotClaimableStatusFragment and current status so errors.Is, ParseClaimConflict, and batch exit codes can match it; the wrapper is a *publicops.ClaimConflictError carrying IssueID/Assignee/Status typed fields.

Source

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

		// Idempotent: if already claimed in_progress by the same actor —
		// including a spelling difference across layers (ga-wzl83) — treat as
		// success. This supports agent retry workflows where claim may be
		// called multiple times after transient failures (GH#8).
		if actorMatches(assignee, actor) && currentStatus == types.StatusInProgress {
			return &ClaimResult{OldIssue: oldIssue, IsWisp: isWisp}, nil
		}
		// The refusal carries the state that lost the CAS, read just above in
		// THIS transaction, so a caller learns who won without parsing the
		// message. The typed wrapper carries the fields; the PROSE is composed
		// here, because ClaimConflictError.Error() passes its wrapped refusal
		// through byte-for-byte — a bare sentinel would reach the caller as
		// "issue already claimed" with the holder dropped and
		// beads.ParseClaimConflict unable to recover it. The fragments are the
		// storage layer's exported ones, which is what keeps the parser and
		// this producer in step. The sentinel stays matchable through both
		// wraps, which errors.Is, ParseClaimConflict and the proxied batch
		// exit code all key on.
		refusal := fmt.Errorf("%w%s%s", storage.ErrNotClaimable, storage.NotClaimableStatusFragment, currentStatus)
		if assignee != "" && !actorMatches(assignee, actor) {
			switch {
			// A pool-assigned issue reaches here only when the CAS lost for a
			// non-assignee reason (status changed underneath us): report the
			// 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.

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check the typed ClaimConflictError.Status field to see the blocking status
  2. Use bd ready to pick claimable work instead of claiming arbitrary IDs
  3. If the status is stale/incorrect, transition the issue to open first (e.g. bd update/reopen)
  4. Handle errors.Is(err, storage.ErrNotClaimable) and move on to the next ready issue

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.ErrNotClaimable) {
    log.Printf("issue %s not claimable (status=%s)", conflict.IssueID, conflict.Status)
    return nextReadyIssue()
}
Defensive patterns

Strategy: type-guard

Validate before calling

// check claimability before claiming
iss, _ := GetIssueInTx(ctx, tx, id)
claimable := iss.Status == types.StatusOpen || customActiveStatuses[iss.Status]
if !claimable { return fmt.Errorf("%s is %s, not claimable", id, iss.Status) }

Type guard

func isNotClaimable(err error) (id, status string, ok bool) {
    var c *publicops.ClaimConflictError
    if errors.As(err, &c) && errors.Is(err, storage.ErrNotClaimable) {
        return c.IssueID, string(c.Status), true
    }
    return "", "", false
}

Try / catch

var c *publicops.ClaimConflictError
if errors.As(err, &c) && errors.Is(err, storage.ErrNotClaimable) {
    log.Printf("skip %s: status=%s", c.IssueID, c.Status)
    return nil // move to next ready issue
}

Prevention

When it happens

Trigger: Calling ClaimIssueInTx on an issue whose current status is in_progress/blocked/closed/frozen (or a wip/done/frozen custom status) and whose assignee is empty or matches the actor pool case where status changed underneath the claimer.

Common situations: Two agents racing where one transitions the issue to in_progress/blocked before the other's CAS lands; claiming an already-closed or frozen issue; claiming an issue whose custom status is not in the 'active' category so it isn't claimable.

Related errors


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