gastownhall/beads · warning · storage.ErrAssigneeMismatch

%w: %s is held by %q, expected %q

Error message

%w: %s is held by %q, expected %q

What it means

The compare-and-swap precondition failed: the issue's current assignee does not actor-match expectedAssignee, so UnclaimIssueIfAssigneeInTx returns storage.ErrAssigneeMismatch and leaves the row untouched. This is the designed 'someone else holds it (or it was released)' verdict — including an empty current assignee meaning the claim is already gone. Matching is done under actorMatches, so identity spelling variants of the same holder still count as a match.

Source

Thrown at internal/storage/issueops/unclaim.go:181

	oldIssue, err := GetIssueInTx(ctx, tx, id)
	if err != nil {
		return fmt.Errorf("failed to get issue for unclaim: %w", err)
	}

	// Validate: cannot unclaim closed issues.
	if oldIssue.Status == types.StatusClosed {
		return fmt.Errorf("cannot unclaim closed issue %s", id)
	}

	// Compare-and-swap precheck: a mismatched holder — including an
	// already-released issue (empty assignee) — is a loud, typed no-op. Judged
	// under actorMatches (ga-5ksp5), not verbatim equality, so expectedAssignee
	// spelled under a different layer's separator convention than the stored
	// assignee still counts as a match. The read and the UPDATE below run in
	// the same transaction, so this check and the CAS WHERE clause see the
	// same row state.
	if !actorMatches(oldIssue.Assignee, expectedAssignee) {
		return fmt.Errorf("%w: %s is held by %q, expected %q", storage.ErrAssigneeMismatch, id, oldIssue.Assignee, expectedAssignee)
	}

	now := time.Now().UTC()

	// Atomic UPDATE CASed on row_lock rather than assignee (ga-5ksp5): the
	// Go-side check above already authorized the swap under actorMatches
	// against the row read into oldIssue, and row_lock is rewritten by every
	// path that mutates status/assignee/started_at (see the freshRowLock
	// invariant in lease.go) — so requiring it to still equal
	// oldIssue.RowVersion applies the same transition as UnclaimIssueInTx
	// (assignee cleared, status reopened, started_at cleared, row_lock
	// rewritten) while detecting a racing reclaim/close on the same row exactly
	// as precisely as the old `assignee = <expectedAssignee>` predicate did,
	// without embedding a spelling-sensitive string comparison in SQL.
	result, err := tx.ExecContext(ctx, fmt.Sprintf(`
		UPDATE %s
		SET assignee = '', status = 'open', updated_at = ?,
		    started_at = NULL, row_lock = ?

View on GitHub (pinned to 71377f2769)

Solutions

  1. Treat ErrAssigneeMismatch as an expected outcome: re-read the issue and skip the release if the holder changed.
  2. If the claim is already released (current assignee empty), consider the operation done — do not retry.
  3. Verify expectedAssignee is the correct holder identity, not a different actor.
  4. If the new holder's claim is abandoned, use UnclaimIssueInTx with force=true instead of the conditional path.

Example fix

// before — treating mismatch as a hard failure
err := issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, holder)
if err != nil { return err }
// after — tolerate the CAS verdict
if err != nil && errors.Is(err, storage.ErrAssigneeMismatch) {
    return nil // claim moved or already released; skip
}
Defensive patterns

Strategy: type-guard

Validate before calling

issue, _ := storage.GetIssue(ctx, id)
if issue == nil || !actorMatches(issue.Assignee, expectedAssignee) {
    return nil // mismatch or gone; skip conditional unclaim
}

Type guard

func isAssigneeMismatch(err error) bool {
    return errors.Is(err, storage.ErrAssigneeMismatch)
}

Try / catch

err := issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, holder)
if isAssigneeMismatch(err) {
    // expected CAS verdict: holder changed or claim already released
    return nil
}
return err

Prevention

When it happens

Trigger: Calling UnclaimIssueIfAssigneeInTx when the issue is now assigned to someone else, was re-claimed by another agent, or was already unclaimed (assignee is empty). Also on the retry path at line 222 after a 0-row CAS where the re-read shows a different holder.

Common situations: Two workers race to release the same claim; the claim moved to a new owner between when you read it and when you called; the issue was released earlier and a stale job retries the release; expectedAssignee refers to a genuinely different identity than the stored holder.

Related errors


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