gastownhall/beads · error · storage.ErrNotOwner
%w: %s is held by %s; coordinate with the holder — pass --fo
Error message
%w: %s is held by %s; coordinate with the holder — pass --force only if their claim is abandoned (crashed agent, expired lease)
What it means
UnclaimIssueInTx enforces ownership: without force, only a caller whose actor matches the current assignee may release the claim. The error wraps storage.ErrNotOwner and tells the caller the claim is held by someone else, advising --force only when the holder is genuinely gone (crashed agent, expired lease). Comparison uses actorMatches, so equivalent identity spellings are accepted.
Source
Thrown at internal/storage/issueops/unclaim.go:61
return fmt.Errorf("failed to get issue for unclaim: %w", err)
}
// Validate: cannot unclaim closed issues
if oldIssue.Status == types.StatusClosed {
return fmt.Errorf("cannot unclaim closed issue %s", id)
}
// Validate: must have an assignee to unclaim
if oldIssue.Assignee == "" {
return fmt.Errorf("issue %s is not assigned", id)
}
// Validate ownership unless the caller forced the release. Without force, a
// process may only release its own claim. Compared under actorMatches, not
// verbatim, so a caller naming its own identity under a different layer's
// spelling (ga-5ksp5) is not refused as a stranger.
if !force && !actorMatches(oldIssue.Assignee, actor) {
return fmt.Errorf("%w: %s is held by %s; coordinate with the holder — pass --force only if their claim is abandoned (crashed agent, expired lease)",
storage.ErrNotOwner, id, oldIssue.Assignee)
}
now := time.Now().UTC()
// Atomic UPDATE: clear assignee, reset status to open, clear started_at,
// and rewrite row_lock. The predicate CASes on row_lock rather than
// assignee (ga-5ksp5): ownership was already authorized above (or bypassed
// by force) against the row read into oldIssue, and row_lock is rewritten
// by every path that mutates status/assignee/started_at (see the
// freshRowLock invariant in lease.go) — so requiring it to still equal
// oldIssue.RowVersion detects a claim that changed hands (or was released,
// or closed) between that read and this write exactly as precisely as the
// old `assignee = <actor>` predicate did, without embedding a
// spelling-sensitive string comparison in SQL. force does not exempt this
// check: force only widens WHO may unclaim, not whether the row is still
// the one we read.
result, err := tx.ExecContext(ctx, fmt.Sprintf(`View on GitHub (pinned to 71377f2769)
Solutions
- Pass the same actor identity that originally claimed the issue (check your actor config/env).
- If the holder is confirmed dead (crashed/expired lease), call with force=true to take over.
- Coordinate with the holder to have them release their own claim.
- Fix actor naming inconsistencies so actorMatches recognizes your own claim.
Example fix
// before err := store.ReleaseIssue(ctx, id) // wrong actor identity -> ErrNotOwner // after // confirmed other agent crashed: err := store.ReleaseIssue(ctx, id, storage.WithForce()) // or for your own claim, run with the correct actor identity
Defensive patterns
Strategy: validation
Validate before calling
iss, err := store.GetIssue(ctx, id)
if err != nil { return err }
if iss.Assignee != "" && iss.Assignee != myActor {
// holder is someone else — do not force without confirming they are gone
return fmt.Errorf("claim held by %s", iss.Assignee)
} Try / catch
if err := store.ReleaseIssue(ctx, id); err != nil {
if errors.Is(err, storage.ErrNotOwner) {
// prompt for --force or abort; never auto-force
}
} Prevention
- Configure a stable, consistent actor identity for every process.
- Only use --force after verifying the holder is actually gone.
- Use leases/heartbeats so abandoned claims are detectable before forcing.
- Check assignee ownership before calling release in admin tooling.
When it happens
Trigger: Calling ReleaseIssueInTx/UnclaimIssueInTx with force=false where the actor does not match the issue's current Assignee, e.g. agent B releasing agent A's claim, or an inconsistently configured actor identity.
Common situations: An operator cleaning up after a crashed agent; a supervisor releasing workers' claims without force; misconfigured actor name so a process doesn't recognize its own claim.
Related errors
- lock busy: held by another process
- lock already held by another process
- lock already held by another process
- ErrBusy
- timeout waiting for cache lock on %s
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/833479714a399e83.
Report an issue: GitHub.