gastownhall/beads · warning · storage.ErrNotClaimable

%w: %s is ephemeral

Error message

%w: %s is ephemeral

What it means

HeartbeatIssue refreshes an actor's lease on an in_progress issue, but wisps (ephemeral issues) are never leased and cannot be heartbeaten. If the target id is an active wisp, the call returns storage.ErrNotClaimable wrapped with '<id> is ephemeral'. This is a sentinel-wrapped error — callers should test with errors.Is(err, storage.ErrNotClaimable).

Source

Thrown at internal/storage/embeddeddolt/issues.go:121

			if err := issueops.CheckVersionInTx(ctx, tx, id, *opts.ExpectedVersion); err != nil {
				return err
			}
		}
		if err := issueops.CheckExpectedFieldsInTx(ctx, tx, id, opts.ExpectedAssignee, opts.ExpectedStatus); err != nil {
			return err
		}
		_, err := issueops.UpdateIssueInTx(ctx, tx, id, updates, actor)
		return err
	})
}

// HeartbeatIssue refreshes the lease on an issue actor holds in_progress.
// Delegates SQL work to issueops; EmbeddedDolt auto-commits the transaction.
func (s *EmbeddedDoltStore) HeartbeatIssue(ctx context.Context, id, actor string) error {
	return s.withConn(ctx, true, func(tx *sql.Tx) error {
		if issueops.IsActiveWispInTx(ctx, tx, id) {
			// Wisps are ephemeral and never leased; nothing to heartbeat.
			return fmt.Errorf("%w: %s is ephemeral", storage.ErrNotClaimable, id)
		}
		return issueops.HeartbeatIssueInTx(ctx, tx, id, actor)
	})
}

// ReclaimExpiredLeases reverts in_progress issues whose lease expired more than
// olderThan ago back to ready, recovering work stranded by dead workers.
func (s *EmbeddedDoltStore) ReclaimExpiredLeases(ctx context.Context, olderThan time.Duration, filter types.ReclaimFilter, actor string) ([]types.ReclaimedLease, error) {
	cutoff := time.Now().UTC().Add(-olderThan)
	var reclaimed []types.ReclaimedLease
	err := s.withConn(ctx, true, func(tx *sql.Tx) error {
		var err error
		reclaimed, err = issueops.ReclaimExpiredLeasesInTx(ctx, tx, cutoff, filter, actor)
		return err
	})
	return reclaimed, err
}

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check errors.Is(err, storage.ErrNotClaimable) and treat wisps as lease-free — skip heartbeating for them.
  2. Promote/resolve the wisp instead of heartbeating if the work is ongoing.
  3. Track whether the claimed id came from a wisp-producing flow and bypass the heartbeat loop for those.
  4. Upgrade client code that predates wisps to use the claim API's wisp awareness.

Example fix

// before: blind heartbeat loop fails on wisps
if err := store.HeartbeatIssue(ctx, id, actor); err != nil {
    return err
}
// after: treat ErrNotClaimable as skip, not fatal
if err := store.HeartbeatIssue(ctx, id, actor); err != nil {
    if errors.Is(err, storage.ErrNotClaimable) {
        return nil // ephemeral wisp: no lease to renew
    }
    return err
}
Defensive patterns

Strategy: type-guard

Validate before calling

// skip heartbeats for wisps up front
func isEphemeralWisp(store *embeddeddolt.EmbeddedDoltStore, ctx context.Context, id string) bool {
    return issueops.IsActiveWisp(ctx, store.DB(), id)
}

Type guard

func notClaimable(err error) bool {
    return errors.Is(err, storage.ErrNotClaimable)
}

Try / catch

err := store.HeartbeatIssue(ctx, id, actor)
switch {
case err == nil:
    // lease renewed
case errors.Is(err, storage.ErrNotClaimable):
    // ephemeral wisp: no lease to renew, not a failure
    err = nil
default:
    return err
}

Prevention

When it happens

Trigger: Calling store.HeartbeatIssue(ctx, id, actor) where issueops.IsActiveWispInTx reports the id is an active wisp — i.e. the agent heartbeating the claim actually holds a wisp created for ephemeral work, not a durable issue.

Common situations: An agent claiming work via a formula/molecule flow receives a wisp id and then runs a generic heartbeat loop against it; confusion between wisp ids and their promoted issue ids; stale client code written before wisps existed.

Related errors


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