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 errView on GitHub (pinned to 71377f2769)
Solutions
- Check whether the issue is a wisp (e.g. bd show <id> / wisp status) before heartbeating
- Exclude wisps from heartbeat loops — wisps are ephemeral and need no lease renewal
- If the id should be a durable issue, recreate it as a regular issue instead of a wisp
- 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
- Filter wisps out of heartbeat loops
- Check issue kind (wisp vs regular) before any lease/claim operations
- Handle storage.ErrNotClaimable explicitly in lease-management code
- Recreate ephemeral work as regular issues if it needs leases
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
- %w: %s is ephemeral
- storage.ErrNotClaimable
- db: IssueSQLRepository.HeartbeatIssue: %w
- Heartbeat: id must not be empty
- Heartbeat: %w
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/21b3e1eaa3b8d129.
Report an issue: GitHub.