gastownhall/beads · error · storage.ErrNotOwner

: is held by ; coordinate with the holder — pass --force…

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.

Solutions

  1. Pass the same actor identity that originally claimed the issue (check your actor config/env).
  2. If the holder is confirmed dead (crashed/expired lease), call with force=true to take over.
  3. Coordinate with the holder to have them release their own claim.
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/833479714a399e83. Report an issue: GitHub.

Appendix: 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)