{"record":{"id":"c9550aef3706bb40","repo":"gastownhall/beads","slug":"w-s-is-ephemeral-c9550a","errorCode":null,"errorMessage":"%w: %s is ephemeral","messagePattern":"%w: (.+?) is ephemeral","errorType":"validation","errorClass":"storage.ErrNotClaimable","httpStatus":null,"severity":"warning","filePath":"internal/storage/embeddeddolt/issues.go","lineNumber":121,"sourceCode":"\t\t\tif err := issueops.CheckVersionInTx(ctx, tx, id, *opts.ExpectedVersion); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif err := issueops.CheckExpectedFieldsInTx(ctx, tx, id, opts.ExpectedAssignee, opts.ExpectedStatus); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t_, err := issueops.UpdateIssueInTx(ctx, tx, id, updates, actor)\n\t\treturn err\n\t})\n}\n\n// HeartbeatIssue refreshes the lease on an issue actor holds in_progress.\n// Delegates SQL work to issueops; EmbeddedDolt auto-commits the transaction.\nfunc (s *EmbeddedDoltStore) HeartbeatIssue(ctx context.Context, id, actor string) error {\n\treturn s.withConn(ctx, true, func(tx *sql.Tx) error {\n\t\tif issueops.IsActiveWispInTx(ctx, tx, id) {\n\t\t\t// Wisps are ephemeral and never leased; nothing to heartbeat.\n\t\t\treturn fmt.Errorf(\"%w: %s is ephemeral\", storage.ErrNotClaimable, id)\n\t\t}\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.\nfunc (s *EmbeddedDoltStore) 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.withConn(ctx, true, func(tx *sql.Tx) error {\n\t\tvar err error\n\t\treclaimed, err = issueops.ReclaimExpiredLeasesInTx(ctx, tx, cutoff, filter, actor)\n\t\treturn err\n\t})\n\treturn reclaimed, err\n}\n","sourceCodeStart":103,"sourceCodeEnd":139,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/storage/embeddeddolt/issues.go#L103-L139","documentation":"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).","triggerScenarios":"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.","commonSituations":"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.","solutions":["Check errors.Is(err, storage.ErrNotClaimable) and treat wisps as lease-free — skip heartbeating for them.","Promote/resolve the wisp instead of heartbeating if the work is ongoing.","Track whether the claimed id came from a wisp-producing flow and bypass the heartbeat loop for those.","Upgrade client code that predates wisps to use the claim API's wisp awareness."],"exampleFix":"// before: blind heartbeat loop fails on wisps\nif err := store.HeartbeatIssue(ctx, id, actor); err != nil {\n    return err\n}\n// after: treat ErrNotClaimable as skip, not fatal\nif err := store.HeartbeatIssue(ctx, id, actor); err != nil {\n    if errors.Is(err, storage.ErrNotClaimable) {\n        return nil // ephemeral wisp: no lease to renew\n    }\n    return err\n}","handlingStrategy":"type-guard","validationCode":"// skip heartbeats for wisps up front\nfunc isEphemeralWisp(store *embeddeddolt.EmbeddedDoltStore, ctx context.Context, id string) bool {\n    return issueops.IsActiveWisp(ctx, store.DB(), id)\n}","typeGuard":"func notClaimable(err error) bool {\n    return errors.Is(err, storage.ErrNotClaimable)\n}","tryCatchPattern":"err := store.HeartbeatIssue(ctx, id, actor)\nswitch {\ncase err == nil:\n    // lease renewed\ncase errors.Is(err, storage.ErrNotClaimable):\n    // ephemeral wisp: no lease to renew, not a failure\n    err = nil\ndefault:\n    return err\n}","preventionTips":["Track whether a claimed id originated from a wisp-producing flow.","Use errors.Is against storage.ErrNotClaimable, never string matching.","Promote wisps to durable issues before running lease loops.","Update legacy heartbeat loops to be wisp-aware."],"tags":["heartbeat","wisp","lease","sentinel-error"],"backgroundTag":"issue-not-claimable","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}