{"record":{"id":"a21a83515a089f75","repo":"gastownhall/beads","slug":"w-s-is-held-by-q-expected-q-a21a83","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/update_cas.go","lineNumber":53,"sourceCode":"\tif expectedAssignee == nil && expectedStatus == nil {\n\t\treturn nil\n\t}\n\tisWisp := IsActiveWispInTx(ctx, tx, id)\n\tissueTable, _, _, _ := WispTableRouting(isWisp)\n\n\tvar assignee sql.NullString\n\tvar status string\n\terr := tx.QueryRowContext(ctx,\n\t\tfmt.Sprintf(\"SELECT assignee, status FROM %s WHERE id = ?\", issueTable), id,\n\t).Scan(&assignee, &status)\n\tif errors.Is(err, sql.ErrNoRows) {\n\t\treturn fmt.Errorf(\"%w: issue %s\", storage.ErrNotFound, id)\n\t}\n\tif err != nil {\n\t\treturn fmt.Errorf(\"failed to read assignee/status for %s: %w\", id, err)\n\t}\n\tif expectedAssignee != nil && !actorMatches(assignee.String, *expectedAssignee) {\n\t\treturn fmt.Errorf(\"%w: %s is held by %q, expected %q\", storage.ErrAssigneeMismatch, id, assignee.String, *expectedAssignee)\n\t}\n\tif expectedStatus != nil && status != *expectedStatus {\n\t\treturn fmt.Errorf(\"%w: %s has status %q, expected %q\", storage.ErrStatusMismatch, id, status, *expectedStatus)\n\t}\n\treturn nil\n}\n","sourceCodeStart":35,"sourceCodeEnd":60,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/update_cas.go#L35-L60","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Treat errors.Is(err, storage.ErrAssigneeMismatch) as 'someone else owns it' — skip or re-fetch instead of retrying blindly","Re-read the issue, and only retry if the new assignee is acceptable (e.g. claim after release)","Use CAS on a less contended field or rely on row-version checks when assignee semantics are not required","Surface the actual holder (in the error text) to the user/agent so it can coordinate"],"exampleFix":"// before\nif err := ExecuteUpdate(...); err != nil { return err } // treats contention as fatal\n// after\nvar amErr *storage.AssigneeMismatch\nif errors.As(err, &amErr) || errors.Is(err, storage.ErrAssigneeMismatch) {\n    log.Printf(\"issue %s already claimed: %v\", id, err)\n    return nil // pick another issue\n}","handlingStrategy":"fallback","validationCode":"iss, _ := store.GetIssue(ctx, id)\nif expAssignee != nil && iss.Assignee != *expAssignee { return fmt.Errorf(\"pre-check: held by %q\", iss.Assignee) }","typeGuard":null,"tryCatchPattern":"err := ExecuteUpdate(ctx, tx, id, updates, exp)\nif errors.Is(err, storage.ErrAssigneeMismatch) {\n    return pickAnotherIssue(ctx) // someone else holds it\n}","preventionTips":["Design claim flows to skip on ErrAssigneeMismatch rather than retry-spam","Re-read the issue after mismatch and coordinate with the listed holder","Prefer short claim-release cycles to reduce contention windows"],"tags":["go","cas","concurrency","assignee","conflict"],"backgroundTag":"optimistic-lock-conflict","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}