{"record":{"id":"664eaf830470e509","repo":"gastownhall/beads","slug":"storage-errnotclaimable","errorCode":"storage.ErrNotClaimable","errorMessage":"db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral","messagePattern":"db: IssueSQLRepository\\.HeartbeatIssue: %w: (.+?) is ephemeral","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/storage/domain/db/issue.go","lineNumber":1245,"sourceCode":"func (r *issueSQLRepositoryImpl) UnclaimIssueIfAssignee(ctx context.Context, id, actor, expectedAssignee string) error {\n\tif err := issueops.UnclaimIssueIfAssigneeInTx(ctx, r.runner, id, actor, expectedAssignee); err != nil {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.UnclaimIssueIfAssignee: %w\", err)\n\t}\n\treturn nil\n}\n\n// HeartbeatIssue refreshes the lease on an issue actor holds in_progress,\n// mirroring DoltStore.HeartbeatIssue: wisps are ephemeral and never leased,\n// and the SQL work is the classic issueops.HeartbeatIssueInTx — same clock\n// (time.Now().UTC()), same TTL resolution (issueops.LeaseTTL), and the same\n// only-current-owner classification (storage.ErrAlreadyClaimed /\n// ErrNotClaimable) — so classic `bd reclaim` staleness semantics see proxied\n// heartbeats identically. Deliberately NO Dolt commit: the leases table is\n// dolt_ignored (bd-lrgn1), and the cmd layer commits this transaction with\n// uow.RunTxEphemeral (plain SQL COMMIT, nothing in dolt_log).\nfunc (r *issueSQLRepositoryImpl) HeartbeatIssue(ctx context.Context, id, actor string) error {\n\tif issueops.IsActiveWispInTx(ctx, r.runner, id) {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.HeartbeatIssue: %w: %s is ephemeral\", storage.ErrNotClaimable, id)\n\t}\n\tif err := issueops.HeartbeatIssueInTx(ctx, r.runner, id, actor); err != nil {\n\t\treturn fmt.Errorf(\"db: IssueSQLRepository.HeartbeatIssue: %w\", err)\n\t}\n\treturn nil\n}\n\n// WakeExpiredDefers runs the shared lazy defer-wake body against this\n// repository's runner (the same DBTX-shaped seam ReclaimExpiredLeases uses)\n// and reports how many rows woke per table. The issues count decides whether\n// the transaction's owner mints a dolt commit; the wisps count decides\n// whether it must still issue a plain SQL commit — wisp tables are\n// dolt_ignored, so a wisp-only wake mints no version commit, but a caller\n// that treats it as \"nothing happened\" rolls the wisp writes back.\nfunc (r *issueSQLRepositoryImpl) WakeExpiredDefers(ctx context.Context) (issues, wisps int, err error) {\n\tout, err := issueops.WakeExpiredDefersInTx(ctx, r.runner)\n\tif err != nil {\n\t\treturn 0, 0, fmt.Errorf(\"db: IssueSQLRepository.WakeExpiredDefers: %w\", err)","sourceCodeStart":1227,"sourceCodeEnd":1263,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/domain/db/issue.go#L1227-L1263","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nif err := repo.HeartbeatIssue(ctx, id, actor); err != nil { return err }\n// after\nif err := repo.HeartbeatIssue(ctx, id, actor); err != nil {\n    if errors.Is(err, storage.ErrNotClaimable) { return nil } // ephemeral wisp: no heartbeat needed\n    return err\n}","handlingStrategy":"type-guard","validationCode":"// check issue type before heartbeating\nissue, err := repo.Get(ctx, id)\nif err != nil { return err }\nif issue.Ephemeral { return nil } // wisp: skip issue-lease heartbeat","typeGuard":"func isNotClaimable(err error) bool {\n    return errors.Is(err, storage.ErrNotClaimable)\n}","tryCatchPattern":"if err := repo.HeartbeatIssue(ctx, id, actor); err != nil {\n    if isNotClaimable(err) { return nil } // ephemeral wisp: exempt\n    return err\n}","preventionTips":["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."],"tags":["wisp","claim","lease","sentinel"],"backgroundTag":"issue-not-claimable","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}