{"record":{"id":"21b3e1eaa3b8d129","repo":"gastownhall/beads","slug":"w-s-is-ephemeral","errorCode":null,"errorMessage":"%w: %s is ephemeral","messagePattern":"%w: (.+?) is ephemeral","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/dolt/issues.go","lineNumber":457,"sourceCode":"\t}\n\tdoltMetrics.claimVerifyLost.Add(ctx, 1, metric.WithAttributes(\n\t\tattribute.String(\"op\", \"ready-claim\")))\n\treturn 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\",\n\t\tclaimed.ID, assignee, status, post.desc)\n}\n\n// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,\n// pushing lease_expires_at forward on its row in the ephemeral leases table\n// (see issueops.lease). Deliberately NO DOLT_ADD/DOLT_COMMIT: the leases\n// table is dolt_ignored, so a heartbeat mints no commit and no history — this\n// is the whole point of bd-lrgn1 (fleet heartbeats were the dominant source\n// of unbounded reachable history). Wrapped in withRetryTx so a heartbeat that\n// loses Dolt's optimistic merge to a concurrent reclaim/close on the same\n// lease row is replayed against a fresh snapshot rather than surfaced.\nfunc (s *DoltStore) HeartbeatIssue(ctx context.Context, id, actor string) error {\n\tif s.isActiveWisp(ctx, id) {\n\t\t// Wisps are ephemeral and never leased; nothing to heartbeat.\n\t\treturn fmt.Errorf(\"%w: %s is ephemeral\", storage.ErrNotClaimable, id)\n\t}\n\treturn s.withRetryTx(ctx, func(tx *sql.Tx) error {\n\t\treturn issueops.HeartbeatIssueInTx(ctx, tx, id, actor)\n\t})\n}\n\n// ReclaimExpiredLeases reverts in_progress issues whose lease expired more than\n// olderThan ago back to ready, recovering work stranded by dead workers. The\n// reclaim rewrites row_lock so it conflicts with any racing heartbeat/close on\n// the same row; withRetryTx replays the loser. Returns the reclaimed issues.\nfunc (s *DoltStore) ReclaimExpiredLeases(ctx context.Context, olderThan time.Duration, filter types.ReclaimFilter, actor string) ([]types.ReclaimedLease, error) {\n\tcutoff := time.Now().UTC().Add(-olderThan)\n\tvar reclaimed []types.ReclaimedLease\n\terr := s.withRetryTx(ctx, func(tx *sql.Tx) error {\n\t\tvar err error\n\t\treclaimed, err = issueops.ReclaimExpiredLeasesInTx(ctx, tx, cutoff, filter, actor)\n\t\tif err != nil {\n\t\t\treturn err","sourceCodeStart":439,"sourceCodeEnd":475,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/dolt/issues.go#L439-L475","documentation":"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.","triggerScenarios":"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.","commonSituations":"Agents iterating over a mixed list of regular issues and wisps; legacy tooling written before wisps existed; id confusion after issue migration or import.","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"],"exampleFix":"// before\nfor _, id := range claimedIDs { _ = store.HeartbeatIssue(ctx, id, actor) }\n// after\nfor _, id := range claimedIDs {\n    if isWisp(id) { continue }\n    if err := store.HeartbeatIssue(ctx, id, actor); err != nil && !errors.Is(err, storage.ErrNotClaimable) {\n        return err\n    }\n}","handlingStrategy":"type-guard","validationCode":"const issue = await bd.show(id);\nif (issue.isWisp) throw new Error(`${id} is ephemeral; skip heartbeat`);","typeGuard":"function isHeartbeatable(issue) {\n  return issue && !issue.isWisp && issue.status === \"in_progress\" && issue.assignee === actor;\n}","tryCatchPattern":"try {\n  await store.HeartbeatIssue(ctx, id, actor);\n} catch (e) {\n  if (errors.Is(err, storage.ErrNotClaimable)) return; // skip wisps\n  if (String(e.message).includes(\"is ephemeral\")) return;\n  throw e;\n}","preventionTips":["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"],"tags":["wisp","lease","heartbeat","not-claimable"],"backgroundTag":"ephemeral-not-claimable","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}