gastownhall/beads · error

%w: %s is ephemeral

Error message

%w: %s is ephemeral

What it means

HeartbeatIssue refreshes a lease on an issue the actor holds in_progress, but wisps (ephemeral issues) are never leased, so heartbeating one is invalid. The store returns ErrNotClaimable wrapped with the issue id rather than silently succeeding.

Source

Thrown at internal/storage/dolt/issues.go:457

	}
	doltMetrics.claimVerifyLost.Add(ctx, 1, metric.WithAttributes(
		attribute.String("op", "ready-claim")))
	return nil, fmt.Errorf("ready claim of %s reported success but did not land (found assignee=%q status=%q, want %s) — server likely degraded; treat the claim as NOT applied",
		claimed.ID, assignee, status, post.desc)
}

// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,
// pushing lease_expires_at forward on its row in the ephemeral leases table
// (see issueops.lease). Deliberately NO DOLT_ADD/DOLT_COMMIT: the leases
// table is dolt_ignored, so a heartbeat mints no commit and no history — this
// is the whole point of bd-lrgn1 (fleet heartbeats were the dominant source
// of unbounded reachable history). Wrapped in withRetryTx so a heartbeat that
// loses Dolt's optimistic merge to a concurrent reclaim/close on the same
// lease row is replayed against a fresh snapshot rather than surfaced.
func (s *DoltStore) HeartbeatIssue(ctx context.Context, id, actor string) error {
	if s.isActiveWisp(ctx, id) {
		// Wisps are ephemeral and never leased; nothing to heartbeat.
		return fmt.Errorf("%w: %s is ephemeral", storage.ErrNotClaimable, id)
	}
	return s.withRetryTx(ctx, func(tx *sql.Tx) error {
		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. The
// reclaim rewrites row_lock so it conflicts with any racing heartbeat/close on
// the same row; withRetryTx replays the loser. Returns the reclaimed issues.
func (s *DoltStore) 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.withRetryTx(ctx, func(tx *sql.Tx) error {
		var err error
		reclaimed, err = issueops.ReclaimExpiredLeasesInTx(ctx, tx, cutoff, filter, actor)
		if err != nil {
			return err

View on GitHub (pinned to 71377f2769)

Solutions

  1. Check whether the issue is a wisp (e.g. bd show <id> / wisp status) before heartbeating
  2. Exclude wisps from heartbeat loops — wisps are ephemeral and need no lease renewal
  3. If the id should be a durable issue, recreate it as a regular issue instead of a wisp
  4. Handle storage.ErrNotClaimable in the caller to skip ephemeral ids gracefully

Example fix

// before
for _, id := range claimedIDs { _ = store.HeartbeatIssue(ctx, id, actor) }
// after
for _, id := range claimedIDs {
    if isWisp(id) { continue }
    if err := store.HeartbeatIssue(ctx, id, actor); err != nil && !errors.Is(err, storage.ErrNotClaimable) {
        return err
    }
}
Defensive patterns

Strategy: type-guard

Validate before calling

const issue = await bd.show(id);
if (issue.isWisp) throw new Error(`${id} is ephemeral; skip heartbeat`);

Type guard

function isHeartbeatable(issue) {
  return issue && !issue.isWisp && issue.status === "in_progress" && issue.assignee === actor;
}

Try / catch

try {
  await store.HeartbeatIssue(ctx, id, actor);
} catch (e) {
  if (errors.Is(err, storage.ErrNotClaimable)) return; // skip wisps
  if (String(e.message).includes("is ephemeral")) return;
  throw e;
}

Prevention

When it happens

Trigger: Calling HeartbeatIssue(ctx, id, actor) with an id for which s.isActiveWisp returns true — i.e. an active wisp — typically from an agent loop that heartbeats every claimed issue without distinguishing wisps.

Common situations: Agents iterating over a mixed list of regular issues and wisps; legacy tooling written before wisps existed; id confusion after issue migration or import.

Related errors


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