gastownhall/beads · error · storage.ErrAlreadyClaimed
%w: issue %s is assigned to %q
Error message
%w: issue %s is assigned to %q
What it means
This error wraps storage.ErrAlreadyClaimed and is returned by AuthorizeAssigneeTransferWithPools when an issue has an assignee that is not covered by any allowed 'pool' value, blocking an unguarded transfer of an active assignment. The library throws it to protect active assignments from being silently reassigned through an update request without going through the proper transfer/authorization path.
Source
Thrown at internal/storage/issueops/aggregate.go:173
// because the caller named the holder under a different layer's spelling.
//
// Passing nil pools answers every question except pool membership, so a caller
// that wants the config read only when it matters calls with nil first and
// re-evaluates with the loaded aliases on refusal.
func AuthorizeAssigneeTransferWithPools(before *types.Issue, request publicops.UpdateRequest, pools []string) error {
if !request.Patch.Assignee.Set || actorMatches(request.Patch.Assignee.Value, before.Assignee) || request.ExpectedAssignee != nil || request.ForceAssigneeTransfer || before.Status != types.StatusInProgress || before.Assignee == "" || actorMatches(before.Assignee, request.Actor) {
return nil
}
// Exact-string membership, deliberately not actorMatches (ga-v2k49, same
// reason as claim.go's identical pool checks): a pool alias is a literal
// claim.pools config value, not a Gas Town identity that gets respelled
// per layer, so there is no cross-spelling variant to reconcile.
for _, pool := range pools {
if pool == before.Assignee {
return nil
}
}
return fmt.Errorf("%w: issue %s is assigned to %q", storage.ErrAlreadyClaimed, before.ID, before.Assignee)
}
// AuthorizeAssigneeTransfer protects an active assignment from unguarded transfer.
func AuthorizeAssigneeTransfer(ctx context.Context, tx DBTX, before *types.Issue, request publicops.UpdateRequest) error {
if err := AuthorizeAssigneeTransferWithPools(before, request, nil); err == nil {
return nil
}
pools, err := ClaimPoolAliasesInTx(ctx, tx)
if err != nil {
return err
}
return AuthorizeAssigneeTransferWithPools(before, request, pools)
}
// ApplyMetadataPatch returns the canonical metadata value and whether it changes.
func ApplyMetadataPatch(current json.RawMessage, patch publicops.MetadataPatch) (json.RawMessage, bool, error) {
if !patch.Replace.Set && !patch.Merge.Set && len(patch.Set) == 0 && len(patch.Unset) == 0 {
return current, false, nilView on GitHub (pinned to 71377f2769)
Solutions
- Check the issue's current assignee before updating; if set, use the proper assignment-release flow (clear the assignee with the correct authorization) instead of a plain update.
- Pass the correct pools slice to AuthorizeAssigneeTransferWithPools so the current assignee's pool is included and the transfer is authorized.
- If the assignment is stale (agent died), explicitly release/reclaim the issue through the supported claim workflow, then retry the update.
Example fix
// before
err := issueops.AuthorizeAssigneeTransferWithPools(before, req, nil)
// after
pools := []string{"pool-alpha", agentName} // include the pool that owns the current assignee
err := issueops.AuthorizeAssigneeTransferWithPools(before, req, pools)
if errors.Is(err, storage.ErrAlreadyClaimed) {
// release or coordinate before updating
} Defensive patterns
Strategy: validation
Validate before calling
func canTransfer(before *types.Issue, pools []string) bool {
return before == nil || before.Assignee == "" || slices.Contains(pools, before.Assignee)
} Type guard
func isAlreadyClaimed(err error) bool { return errors.Is(err, storage.ErrAlreadyClaimed) } Try / catch
if err := issueops.AuthorizeAssigneeTransfer(ctx, tx, before, req); err != nil {
if errors.Is(err, storage.ErrAlreadyClaimed) {
return fmt.Errorf("issue %s held by %q; release or include its pool first", before.ID, before.Assignee)
}
return err
} Prevention
- Always fetch the current issue state and check Assignee before issuing an update.
- Pass the full list of agent pools to AuthorizeAssigneeTransferWithPools rather than nil.
- Use the claim/assign workflow for reassignment instead of raw updates.
- Handle storage.ErrAlreadyClaimed explicitly in update paths used by automation.
When it happens
Trigger: Calling AuthorizeAssigneeTransfer (or AuthorizeAssigneeTransferWithPools) with an UpdateRequest that would change or touch an issue whose before.Assignee is non-empty and does not match any entry in the pools slice (pools is nil or does not contain the current assignee).
Common situations: Agents or scripts run `bd update` on an issue that another agent already claimed; concurrent automation both assigning the same issue; passing the wrong pools list (or nil) when the caller only wants to allow reassignment within known agent pools.
Related errors
- applyGraph: node %q: defer assignee: %w
- %w: invalid forced assignee transfer
- %w: %s is held by %q, expected %q
- no store is open for this workspace
- not found
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/5ded3e3d12b36f7c.
Report an issue: GitHub.