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

This is the assignee-mismatch branch of the CAS check: the caller declared an expected assignee, but the row's current assignee does not match (per actorMatches). The library returns storage.ErrAssigneeMismatch wrapped with actual vs expected values so callers can detect a lost update race and re-acquire.

Source

Thrown at internal/storage/issueops/update_cas.go:53

	if expectedAssignee == nil && expectedStatus == nil {
		return nil
	}
	isWisp := IsActiveWispInTx(ctx, tx, id)
	issueTable, _, _, _ := WispTableRouting(isWisp)

	var assignee sql.NullString
	var status string
	err := tx.QueryRowContext(ctx,
		fmt.Sprintf("SELECT assignee, status FROM %s WHERE id = ?", issueTable), id,
	).Scan(&assignee, &status)
	if errors.Is(err, sql.ErrNoRows) {
		return fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
	}
	if err != nil {
		return fmt.Errorf("failed to read assignee/status for %s: %w", id, err)
	}
	if expectedAssignee != nil && !actorMatches(assignee.String, *expectedAssignee) {
		return fmt.Errorf("%w: %s is held by %q, expected %q", storage.ErrAssigneeMismatch, id, assignee.String, *expectedAssignee)
	}
	if expectedStatus != nil && status != *expectedStatus {
		return fmt.Errorf("%w: %s has status %q, expected %q", storage.ErrStatusMismatch, id, status, *expectedStatus)
	}
	return nil
}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Treat errors.Is(err, storage.ErrAssigneeMismatch) as 'someone else owns it' — skip or re-fetch instead of retrying blindly
  2. Re-read the issue, and only retry if the new assignee is acceptable (e.g. claim after release)
  3. Use CAS on a less contended field or rely on row-version checks when assignee semantics are not required
  4. Surface the actual holder (in the error text) to the user/agent so it can coordinate

Example fix

// before
if err := ExecuteUpdate(...); err != nil { return err } // treats contention as fatal
// after
var amErr *storage.AssigneeMismatch
if errors.As(err, &amErr) || errors.Is(err, storage.ErrAssigneeMismatch) {
    log.Printf("issue %s already claimed: %v", id, err)
    return nil // pick another issue
}
Defensive patterns

Strategy: fallback

Validate before calling

iss, _ := store.GetIssue(ctx, id)
if expAssignee != nil && iss.Assignee != *expAssignee { return fmt.Errorf("pre-check: held by %q", iss.Assignee) }

Try / catch

err := ExecuteUpdate(ctx, tx, id, updates, exp)
if errors.Is(err, storage.ErrAssigneeMismatch) {
    return pickAnotherIssue(ctx) // someone else holds it
}

Prevention

When it happens

Trigger: ExecuteUpdate with ExpectedFields.Assignee set while another actor holds the issue — e.g. two agents both claim the same issue, or a human assigned it between fetch and write.

Common situations: Concurrent agents racing to claim issues from a shared queue; a stale local view after another session reassigned the issue; automation expecting 'unassigned' on an already-claimed issue.

Related errors


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