gastownhall/beads · warning

cannot unclaim closed issue %s

Error message

cannot unclaim closed issue %s

What it means

UnclaimIssueInTx refuses to release an issue whose status is closed, returning this plain error. The library throws it because unclaiming is defined as clearing the assignee and resetting status to open; doing that to a closed issue would mutate finished work. Claim release and issue closure are separate lifecycle steps.

Source

Thrown at internal/storage/issueops/unclaim.go:48

//   - Issue has no assignee (nothing to unclaim)
//   - Issue is claimed by a different actor and force is false (ErrNotOwner)
//
//nolint:gosec // G201: table names come from WispTableRouting (hardcoded constants)
func UnclaimIssueInTx(ctx context.Context, tx DBTX, id string, actor string, force bool) error {
	// 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()

View on GitHub (pinned to 71377f2769)

Solutions

  1. Skip the release when the issue is already closed — nothing needs unclaiming.
  2. Check status first via GetIssue and only call Unclaim for open/in_progress issues.
  3. If the claim must be cleared on a closed issue, deliberately reopen it, then unclaim.
  4. Treat this error as benign/no-op in cleanup paths rather than failing shutdown.

Example fix

// before
if err := store.ReleaseIssue(ctx, id); err != nil { return err }
// after
iss, err := store.GetIssue(ctx, id)
if err != nil { return err }
if iss.Status != types.StatusClosed {
	if err := store.ReleaseIssue(ctx, id); err != nil { return err }
}
Defensive patterns

Strategy: validation

Validate before calling

iss, err := store.GetIssue(ctx, id)
if err != nil { return err }
if iss.Status == types.StatusClosed {
	return nil // nothing to unclaim
}

Try / catch

if err := store.ReleaseIssue(ctx, id); err != nil {
	if strings.Contains(err.Error(), "cannot unclaim closed issue") {
		return nil // already closed; expected during cleanup
	}
	return err
}

Prevention

When it happens

Trigger: Calling ReleaseIssueInTx/UnclaimIssueInTx on an issue with Status == types.StatusClosed, e.g. an agent that closed the issue on completion and then attempts to release its claim during cleanup.

Common situations: Double cleanup: close-on-completion plus a shutdown hook that releases claims; stale batch scripts releasing claims for already-closed issues.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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