gastownhall/beads · error
storage.ErrNotClaimable
storage.ErrNotClaimable
Error message
db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral
What it means
Sentinel-wrapped refusal to heartbeat (refresh the lease of) an active wisp: wisps are ephemeral and their claims work differently, so HeartbeatIssue treats them as not claimable via storage.ErrNotClaimable. The message names the specific ephemeral ID. This mirrors classic `bd reclaim` staleness semantics so proxied heartbeats behave identically for issues.
Source
Thrown at internal/storage/domain/db/issue.go:1245
func (r *issueSQLRepositoryImpl) UnclaimIssueIfAssignee(ctx context.Context, id, actor, expectedAssignee string) error {
if err := issueops.UnclaimIssueIfAssigneeInTx(ctx, r.runner, id, actor, expectedAssignee); err != nil {
return fmt.Errorf("db: IssueSQLRepository.UnclaimIssueIfAssignee: %w", err)
}
return nil
}
// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,
// mirroring DoltStore.HeartbeatIssue: wisps are ephemeral and never leased,
// and the SQL work is the classic issueops.HeartbeatIssueInTx — same clock
// (time.Now().UTC()), same TTL resolution (issueops.LeaseTTL), and the same
// only-current-owner classification (storage.ErrAlreadyClaimed /
// ErrNotClaimable) — so classic `bd reclaim` staleness semantics see proxied
// heartbeats identically. Deliberately NO Dolt commit: the leases table is
// dolt_ignored (bd-lrgn1), and the cmd layer commits this transaction with
// uow.RunTxEphemeral (plain SQL COMMIT, nothing in dolt_log).
func (r *issueSQLRepositoryImpl) HeartbeatIssue(ctx context.Context, id, actor string) error {
if issueops.IsActiveWispInTx(ctx, r.runner, id) {
return fmt.Errorf("db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral", storage.ErrNotClaimable, id)
}
if err := issueops.HeartbeatIssueInTx(ctx, r.runner, id, actor); err != nil {
return fmt.Errorf("db: IssueSQLRepository.HeartbeatIssue: %w", err)
}
return nil
}
// WakeExpiredDefers runs the shared lazy defer-wake body against this
// repository's runner (the same DBTX-shaped seam ReclaimExpiredLeases uses)
// and reports how many rows woke per table. The issues count decides whether
// the transaction's owner mints a dolt commit; the wisps count decides
// whether it must still issue a plain SQL commit — wisp tables are
// dolt_ignored, so a wisp-only wake mints no version commit, but a caller
// that treats it as "nothing happened" rolls the wisp writes back.
func (r *issueSQLRepositoryImpl) WakeExpiredDefers(ctx context.Context) (issues, wisps int, err error) {
out, err := issueops.WakeExpiredDefersInTx(ctx, r.runner)
if err != nil {
return 0, 0, fmt.Errorf("db: IssueSQLRepository.WakeExpiredDefers: %w", err)View on GitHub (pinned to 71377f2769)
Solutions
- Check whether the ID is a wisp first ( IsActiveWispInTx / issue type) and skip or use the wisp-appropriate heartbeat path.
- Distinguish via errors.Is(err, storage.ErrNotClaimable) and treat wisps as exempt from issue-lease heartbeats.
- Re-derive the ID from the original claim result instead of caching it across storage types.
- If this is unexpected, verify table routing — the ID may have been written to wisps unintentionally.
Example fix
// before
if err := repo.HeartbeatIssue(ctx, id, actor); err != nil { return err }
// after
if err := repo.HeartbeatIssue(ctx, id, actor); err != nil {
if errors.Is(err, storage.ErrNotClaimable) { return nil } // ephemeral wisp: no heartbeat needed
return err
} Defensive patterns
Strategy: type-guard
Validate before calling
// check issue type before heartbeating
issue, err := repo.Get(ctx, id)
if err != nil { return err }
if issue.Ephemeral { return nil } // wisp: skip issue-lease heartbeat Type guard
func isNotClaimable(err error) bool {
return errors.Is(err, storage.ErrNotClaimable)
} Try / catch
if err := repo.HeartbeatIssue(ctx, id, actor); err != nil {
if isNotClaimable(err) { return nil } // ephemeral wisp: exempt
return err
} Prevention
- Filter ephemeral/wisp IDs out of heartbeat worker queues at enqueue time.
- errors.Is-check storage.ErrNotClaimable wherever claims are touched.
- Don't cache IDs across storage kinds; re-read the issue before renewing.
- Route wisp heartbeats through the wisp-aware path if one is needed.
When it happens
Trigger: Calling HeartbeatIssue(ctx, id, actor) with the ID of an issue for which issueops.IsActiveWispInTx returns true — i.e., the ID belongs to the ephemeral wisp tables, not the durable issues table.
Common situations: A daemon/workflow heartbeat loop built for durable issues pointed at ephemeral wisp IDs after a routing change; stale ID cached from a wisp-creating operation; caller not checking issue type before renewing leases.
Related errors
- %w: %s is ephemeral
- db: IssueSQLRepository.UnclaimIssue: %w
- %w: %s is ephemeral
- storage.ErrNotFound
- ErrAlreadyClaimed
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/664eaf830470e509.
Report an issue: GitHub.