{"record":{"id":"37f84d3223a5403e","repo":"gastownhall/beads","slug":"erralreadyclaimed-37f84d","errorCode":"ErrAlreadyClaimed","errorMessage":"%w: already assigned to %q — coordinate with the holder; if their claim is abandoned (crashed agent), lease expiry will surface it for bd reclaim","messagePattern":"%w: already assigned to %q — coordinate with the holder; if their claim is abandoned \\(crashed agent\\), lease expiry will surface it for bd reclaim","errorType":"exception","errorClass":"ClaimConflictError","httpStatus":null,"severity":"warning","filePath":"internal/storage/issueops/claim.go","lineNumber":184,"sourceCode":"\t\t\t// status rather than a misleading held-by-someone refusal. Checked\n\t\t\t// FIRST, so a pool alias never falls into the holder-steering copy.\n\t\t\t// Exact-string membership, same reason as assigneeOK's identical\n\t\t\t// term above: a pool alias is a literal config value, not a\n\t\t\t// respelled identity.\n\t\t\tcase slices.Contains(pools, assignee):\n\t\t\t\t// refusal already names the status.\n\t\t\tcase currentStatus == types.StatusOpen:\n\t\t\t\t// Do not name a release command here — not `bd unclaim`, not\n\t\t\t\t// `bd unclaim --force`. Refusal copy that names one gets\n\t\t\t\t// pattern-matched by batch agents into an unclaim+claim\n\t\t\t\t// steamroller of live claims (wy-yuclk). Point at the holder;\n\t\t\t\t// bd reclaim is safe to name because it only recovers claims\n\t\t\t\t// whose lease has already expired.\n\t\t\t\t//\n\t\t\t\t// This copy deliberately omits the parseable \" by <assignee>\"\n\t\t\t\t// tail, so ParseClaimConflict recovers the holder from the\n\t\t\t\t// typed field rather than the prose (bd-at6rc).\n\t\t\t\trefusal = fmt.Errorf(\"%w: already assigned to %q — coordinate with the holder; if their claim is abandoned (crashed agent), lease expiry will surface it for bd reclaim\", storage.ErrAlreadyClaimed, assignee)\n\t\t\tdefault:\n\t\t\t\trefusal = fmt.Errorf(\"%w%s%s\", storage.ErrAlreadyClaimed, storage.ClaimedByFragment, assignee)\n\t\t\t}\n\t\t}\n\t\treturn nil, &publicops.ClaimConflictError{\n\t\t\tIssueID:  id,\n\t\t\tAssignee: assignee,\n\t\t\tStatus:   currentStatus,\n\t\t\tErr:      refusal,\n\t\t}\n\t}\n\n\t// Grant the lease: what makes the claim recoverable — a worker that dies\n\t// stops heartbeating and bd reclaim later reverts the issue. Lease rows\n\t// live in the ephemeral leases table (no Dolt commit, node-local). Wisps\n\t// are never leased (they are ephemeral, not reclaimable work).\n\tif !isWisp {\n\t\tif err := UpsertLeaseInTx(ctx, tx, id, actor, now, leaseTTL(ctx)); err != nil {","sourceCodeStart":166,"sourceCodeEnd":202,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/issueops/claim.go#L166-L202","documentation":"Refusal raised when the issue is currently open but assigned to a different actor: anti-steal protection (GH-3570) prevents reassignment. The message wraps storage.ErrAlreadyClaimed with the holder's name and steers toward coordination/lease expiry (bd reclaim) rather than naming an unclaim command. It is delivered inside a *publicops.ClaimConflictError whose typed Assignee field is the authoritative holder (the prose deliberately omits the parseable tail, bd-at6rc).","triggerScenarios":"ClaimIssueInTx where rowsAffected == 0, assignee is non-empty, actorMatches(assignee, actor) is false, and currentStatus == open — i.e. someone else already holds an open claim on this issue.","commonSituations":"Multiple agents/developers claiming from the same queue; a crashed agent left its claim on an open issue; stale local state where another node assigned the issue between your read and claim.","solutions":["Coordinate with the named holder (the typed ClaimConflictError.Assignee field)","Wait for lease expiry and use bd reclaim to recover an abandoned claim","Pick a different issue from bd ready","Handle errors.Is(err, storage.ErrAlreadyClaimed) in batch loops and continue"],"exampleFix":"// before\nres, err := ClaimIssueInTx(ctx, tx, id, actor)\n// after\nres, err := ClaimIssueInTx(ctx, tx, id, actor)\nvar conflict *publicops.ClaimConflictError\nif errors.As(err, &conflict) && errors.Is(err, storage.ErrAlreadyClaimed) {\n    log.Printf(\"%s held by %s — trying next\", conflict.IssueID, conflict.Assignee)\n    return nextReadyIssue()\n}","handlingStrategy":"type-guard","validationCode":"iss, _ := GetIssueInTx(ctx, tx, id)\nif iss.Assignee != \"\" && iss.Assignee != actor && !poolAliases.Contains(iss.Assignee) {\n    return fmt.Errorf(\"%s held by %s\", id, iss.Assignee)\n}","typeGuard":"func isHeldByOther(err error) (holder string, ok bool) {\n    var c *publicops.ClaimConflictError\n    if errors.As(err, &c) && errors.Is(err, storage.ErrAlreadyClaimed) {\n        return c.Assignee, true\n    }\n    return \"\", false\n}","tryCatchPattern":"var c *publicops.ClaimConflictError\nif errors.As(err, &c) && errors.Is(err, storage.ErrAlreadyClaimed) {\n    log.Printf(\"%s held by %s — picking next\", c.IssueID, c.Assignee)\n    return nextReadyIssue()\n}","preventionTips":["Never run unclaim+claim to steal a live claim — coordinate with the holder","For abandoned claims, wait for lease expiry then use bd reclaim","Assign unique actors per agent so conflicts are attributable","In batch workers, treat ErrAlreadyClaimed as 'skip and continue'"],"tags":["claim","concurrency","conflict","anti-steal"],"backgroundTag":"issue-already-claimed","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}