gastownhall/beads · warning
issue %s is not assigned
Error message
issue %s is not assigned
What it means
UnclaimIssueInTx requires an existing assignee to unclaim; if oldIssue.Assignee is empty it returns this error. The library throws it because there is no claim to release on an unassigned issue, and silently succeeding would mask a caller bug or stale information.
Source
Thrown at internal/storage/issueops/unclaim.go:53
// Route to the correct table (issues/wisps) automatically, matching
// ClaimIssueInTx — a wisp claim lives in the wisp tables, so its release
// must update them too rather than no-op against the permanent issues table.
isWisp := IsActiveWispInTx(ctx, tx, id)
issueTable, _, eventTable, _ := WispTableRouting(isWisp)
oldIssue, err := GetIssueInTx(ctx, tx, id)
if err != nil {
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 theView on GitHub (pinned to 71377f2769)
Solutions
- Check Assignee before calling and treat an empty assignee as already-released (no-op).
- Make release logic idempotent: catch this error and continue cleanup.
- Verify which agent actually holds the claim via GetIssue before releasing.
- Fix retry loops so a previously-succeeded release isn't retried.
Example fix
// before
err := store.ReleaseIssue(ctx, id)
if err != nil { return err }
// after
iss, _ := store.GetIssue(ctx, id)
if iss.Assignee == "" { return nil } // already unclaimed
err := store.ReleaseIssue(ctx, id) Defensive patterns
Strategy: validation
Validate before calling
iss, err := store.GetIssue(ctx, id)
if err != nil { return err }
if iss.Assignee == "" {
return nil // nothing to release
} Try / catch
if err := store.ReleaseIssue(ctx, id); err != nil {
if strings.Contains(err.Error(), "is not assigned") {
return nil // idempotent release
}
return err
} Prevention
- Design claim/release flows to be idempotent.
- Re-read issue state immediately before releasing in multi-agent setups.
- Only release claims your own process created.
When it happens
Trigger: Calling ReleaseIssueInTx/UnclaimIssueInTx on an issue whose Assignee field is empty (never claimed, or already released by another process).
Common situations: Racing agents where one already released the claim; retry logic re-running a release that already succeeded; releasing an issue this process never claimed.
Related errors
- cannot unclaim closed issue %s
- workspace already initialized as database %q, but --database
- workspace already initialized as database %q, but --prefix %
- step %s already claimed (status: %s)
- idempotency check failed: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/e4b7c96c382e0cd1.
Report an issue: GitHub.