gastownhall/beads · error

failed to unclaim issue %s: no matching row

Error message

failed to unclaim issue %s: no matching row

What it means

When the guarded UPDATE affects 0 rows, the row changed underneath the reader. UnclaimIssueInTx re-reads the issue; if the re-read fails, or the row no longer matches the guarded WHERE (e.g. status moved to closed or the row lock moved on), it returns this error. The optimistic lock lost the race and no unclaim happened — the caller must re-inspect state and decide again.

Source

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

		    started_at = NULL, row_lock = ?
		WHERE id = ? AND status IN ('open', 'in_progress') AND row_lock = ?
	`, issueTable), now, freshRowLock(), id, oldIssue.RowVersion)
	if err != nil {
		return fmt.Errorf("failed to unclaim issue: %w", err)
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("failed to get rows affected: %w", err)
	}

	if rowsAffected == 0 {
		// The pre-checks passed, so a 0-row result means the row changed
		// underneath us: re-read to disambiguate an ownership change from a
		// status change. actorMatches, not verbatim, mirrors the precheck above.
		current, gerr := GetIssueInTx(ctx, tx, id)
		if gerr != nil {
			return fmt.Errorf("failed to unclaim issue %s: no matching row", id)
		}
		if !force && !actorMatches(current.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, current.Assignee)
		}
		return fmt.Errorf("failed to unclaim issue %s: no matching row", id)
	}

	return finishUnclaimInTx(ctx, tx, eventTable, id, actor, oldIssue)
}

// finishUnclaimInTx applies the post-UPDATE half of a release shared by
// UnclaimIssueInTx and UnclaimIssueIfAssigneeInTx: it drops the lease row (a
// no-op when none exists, e.g. a wisp or an open-but-assigned issue that was
// never leased) and records the "unclaimed" event. The row mutation
// (assignee/status/started_at/row_lock) must already have been applied in tx.
func finishUnclaimInTx(ctx context.Context, tx DBTX, eventTable string, id string, actor string, oldIssue *types.Issue) error {
	if err := DeleteLeaseInTx(ctx, tx, id); err != nil {

View on GitHub (pinned to 71377f2769)

Solutions

  1. Re-read the issue and re-evaluate: retry the full claim-check-release sequence with fresh state.
  2. If the issue is now closed or already unassigned, skip the release — the race resolved the claim.
  3. Surface the conflict instead of looping; add small jitter/backoff before retry.
  4. Restrict release to the owning agent to avoid many processes racing on the same claim.

Example fix

// before
err := store.ReleaseIssue(ctx, id)
if err != nil { return err } // lost race, hard fail
// after
err := store.ReleaseIssue(ctx, id)
if err != nil {
	iss, gerr := store.GetIssue(ctx, id)
	if gerr == nil && (iss.Assignee == "" || iss.Status == types.StatusClosed) {
		return nil // race already resolved
	}
	return err
}
Defensive patterns

Strategy: retry

Try / catch

if err := store.ReleaseIssue(ctx, id); err != nil {
	if strings.Contains(err.Error(), "no matching row") {
		// re-read state; retry once with backoff or treat as resolved
	}
}

Prevention

When it happens

Trigger: Concurrent modification between GetIssue and UPDATE: another process closed the issue, changed the assignee, or bumped row_lock/RowVersion so WHERE (status IN ('open','in_progress') AND row_lock = ?) matched 0 rows.

Common situations: Two agents racing on the same issue; an external process closing the issue while a cleanup hook releases the claim; a lease expirer resetting rows during release.

Related errors


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