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

  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.