{"record":{"id":"1138b5ec4490d406","repo":"gastownhall/beads","slug":"w-s-is-held-by-q-expected-q","errorCode":null,"errorMessage":"%w: %s is held by %q, expected %q","messagePattern":"%w: (.+?) is held by %q, expected %q","errorType":"error_code","errorClass":"storage.ErrAssigneeMismatch","httpStatus":null,"severity":"warning","filePath":"internal/storage/issueops/unclaim.go","lineNumber":181,"sourceCode":"\toldIssue, err := GetIssueInTx(ctx, tx, id)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to get issue for unclaim: %w\", err)\n\t}\n\n\t// Validate: cannot unclaim closed issues.\n\tif oldIssue.Status == types.StatusClosed {\n\t\treturn fmt.Errorf(\"cannot unclaim closed issue %s\", id)\n\t}\n\n\t// Compare-and-swap precheck: a mismatched holder — including an\n\t// already-released issue (empty assignee) — is a loud, typed no-op. Judged\n\t// under actorMatches (ga-5ksp5), not verbatim equality, so expectedAssignee\n\t// spelled under a different layer's separator convention than the stored\n\t// assignee still counts as a match. The read and the UPDATE below run in\n\t// the same transaction, so this check and the CAS WHERE clause see the\n\t// same row state.\n\tif !actorMatches(oldIssue.Assignee, expectedAssignee) {\n\t\treturn fmt.Errorf(\"%w: %s is held by %q, expected %q\", storage.ErrAssigneeMismatch, id, oldIssue.Assignee, expectedAssignee)\n\t}\n\n\tnow := time.Now().UTC()\n\n\t// Atomic UPDATE CASed on row_lock rather than assignee (ga-5ksp5): the\n\t// Go-side check above already authorized the swap under actorMatches\n\t// against the row read into oldIssue, and row_lock is rewritten by every\n\t// path that mutates status/assignee/started_at (see the freshRowLock\n\t// invariant in lease.go) — so requiring it to still equal\n\t// oldIssue.RowVersion applies the same transition as UnclaimIssueInTx\n\t// (assignee cleared, status reopened, started_at cleared, row_lock\n\t// rewritten) while detecting a racing reclaim/close on the same row exactly\n\t// as precisely as the old `assignee = <expectedAssignee>` predicate did,\n\t// without embedding a spelling-sensitive string comparison in SQL.\n\tresult, err := tx.ExecContext(ctx, fmt.Sprintf(`\n\t\tUPDATE %s\n\t\tSET assignee = '', status = 'open', updated_at = ?,\n\t\t    started_at = NULL, row_lock = ?","sourceCodeStart":163,"sourceCodeEnd":199,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/unclaim.go#L163-L199","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Treat ErrAssigneeMismatch as an expected outcome: re-read the issue and skip the release if the holder changed.","If the claim is already released (current assignee empty), consider the operation done — do not retry.","Verify expectedAssignee is the correct holder identity, not a different actor.","If the new holder's claim is abandoned, use UnclaimIssueInTx with force=true instead of the conditional path."],"exampleFix":"// before — treating mismatch as a hard failure\nerr := issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, holder)\nif err != nil { return err }\n// after — tolerate the CAS verdict\nif err != nil && errors.Is(err, storage.ErrAssigneeMismatch) {\n    return nil // claim moved or already released; skip\n}","handlingStrategy":"type-guard","validationCode":"issue, _ := storage.GetIssue(ctx, id)\nif issue == nil || !actorMatches(issue.Assignee, expectedAssignee) {\n    return nil // mismatch or gone; skip conditional unclaim\n}","typeGuard":"func isAssigneeMismatch(err error) bool {\n    return errors.Is(err, storage.ErrAssigneeMismatch)\n}","tryCatchPattern":"err := issueops.UnclaimIssueIfAssigneeInTx(ctx, tx, id, actor, holder)\nif isAssigneeMismatch(err) {\n    // expected CAS verdict: holder changed or claim already released\n    return nil\n}\nreturn err","preventionTips":["Treat ErrAssigneeMismatch as an expected outcome, not a crash.","If the current assignee is empty, the claim is already released — stop retrying.","Re-read the issue after a mismatch to learn the new holder.","Keep expectedAssignee in sync with the holder you actually observed."],"tags":["claim-conflict","concurrency","compare-and-swap","storage"],"backgroundTag":"assignee-mismatch","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}