gastownhall/beads · error
db: Claim %s: read current state: %w
Error message
db: Claim %s: read current state: %w
What it means
This error wraps a failure to re-read the issue's current assignee/status after the claim CAS matched zero rows (the lost-race path). Claim uses this SELECT to build a ClaimRowResult describing who actually holds the issue; if the read fails, Claim aborts with this wrapped error.
Source
Thrown at internal/storage/domain/db/issue.go:500
`, table, rowLockClause, statusPredicate), args...)
}
if err != nil {
return domain.ClaimRowResult{}, fmt.Errorf("db: Claim %s: %w", id, err)
}
rows, err = res.RowsAffected()
if err != nil {
return domain.ClaimRowResult{}, fmt.Errorf("db: Claim %s: rows affected: %w", id, err)
}
}
if rows == 0 {
var currentAssignee sql.NullString
var currentStatus types.Status
//nolint:gosec // G201: table is one of two hardcoded constants
if err := r.runner.QueryRowContext(ctx,
fmt.Sprintf("SELECT assignee, status FROM %s WHERE id = ?", table), id,
).Scan(¤tAssignee, ¤tStatus); err != nil {
return domain.ClaimRowResult{}, fmt.Errorf("db: Claim %s: read current state: %w", id, err)
}
assignee := ""
if currentAssignee.Valid {
assignee = currentAssignee.String
}
return domain.ClaimRowResult{
Updated: false,
CurrentAssignee: assignee,
CurrentAssigneeIsPool: slices.Contains(pools, assignee),
CurrentStatus: currentStatus,
StartedAtWasZero: startedWasZero,
OldIssue: oldIssue,
}, nil
}
// Grant the lease in the ephemeral leases table, mirroring
// issueops.ClaimIssueInTx. Wisps are never leased. This dual must stay in
// lockstep with the primary path (see the row_lock comment above).View on GitHub (pinned to 71377f2769)
Solutions
- Inspect the wrapped cause: if ErrNotFound, the issue was deleted concurrently — refresh the issue list.
- Retry the claim against current state.
- Check for concurrent deletion workflows colliding with claims.
- Verify connection stability if the cause is a network error.
Example fix
// before
res, err := store.Claim(ctx, id, actor, opts) // lost race; opaque read failure
// after
res, err := store.Claim(ctx, id, actor, opts)
if err != nil && res.CurrentAssignee != "" {
log.Printf("issue %s held by %s", id, res.CurrentAssignee)
} Defensive patterns
Strategy: try-catch
Validate before calling
issue, err := store.Get(ctx, id, opts)
if err != nil { return err }
if issue.Assignee != "" && issue.Assignee != actor { return fmt.Errorf("taken by %s", issue.Assignee) } Type guard
func claimLostRace(err error, res domain.ClaimRowResult) bool {
return res.CurrentAssignee != "" && res.CurrentAssignee != actor
} Try / catch
res, err := store.Claim(ctx, id, actor, opts)
if err != nil && strings.Contains(err.Error(), "read current state") {
// lost race or concurrent delete; re-fetch and decide
fresh, gerr := store.Get(ctx, id, opts)
if gerr != nil { return gerr }
_ = fresh
} Prevention
- Treat claims as CAS: always handle the lost-race branch.
- Avoid deleting issues while claim workflows are active.
- Re-fetch current state instead of retrying the same claim blindly.
- Log the current holder from ClaimRowResult for diagnostics.
When it happens
Trigger: Calling Claim where another actor won the race (rows == 0) and the follow-up SELECT assignee, status ... WHERE id = ? fails — connection loss, context cancellation, or the issue being deleted between the UPDATE and SELECT.
Common situations: Two agents claiming the same issue concurrently; issue deleted by another process between CAS and re-read; transient network errors to a remote database.
Related errors
- failed to insert initial issue counter for prefix %q: %w
- db: Claim %s: read old issue: %w
- db: Claim %s: resolve claim pools: %w
- db: Claim %s: resolve claimable statuses: %w
- db: Claim %s: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/e20485c6d0d0223a.
Report an issue: GitHub.