gastownhall/beads · error
Heartbeat: id must not be empty
Error message
Heartbeat: id must not be empty
What it means
Validation error from Heartbeat: the lease-refresh call was made with an empty issue id. Thrown before touching the ephemeral leases table, so the lease is untouched. Heartbeat only works for an issue the actor holds in_progress, and the empty-id case is a pure caller contract violation.
Source
Thrown at internal/storage/domain/issue.go:1912
// issueops — which is what makes `bd unclaim --if-assignee` behave identically
// on the proxied-server and embedded backends.
func (u *issueUseCaseImpl) UnclaimIfAssignee(ctx context.Context, id, actor, expectedAssignee string) error {
if id == "" {
return fmt.Errorf("UnclaimIfAssignee: id must not be empty")
}
if err := u.issueRepo.UnclaimIssueIfAssignee(ctx, id, actor, expectedAssignee); err != nil {
return fmt.Errorf("UnclaimIfAssignee: %w", err)
}
return nil
}
// Heartbeat refreshes the lease on an issue actor holds in_progress. The
// write touches ONLY the ephemeral leases table (bd-lrgn1), so the caller
// must run it under uow.RunTxEphemeral's no-Dolt-commit form — a heartbeat
// mints no Dolt commit and no history in any mode (bd-aq0ql).
func (u *issueUseCaseImpl) Heartbeat(ctx context.Context, id, actor string) error {
if id == "" {
return fmt.Errorf("Heartbeat: id must not be empty")
}
if err := u.issueRepo.HeartbeatIssue(ctx, id, actor); err != nil {
return fmt.Errorf("Heartbeat: %w", err)
}
return nil
}
// WakeExpiredDefers returns every expired DATED defer to open (see
// issueops.WakeExpiredDefersInTx) and reports how many permanent issues and
// wisps woke. The caller owns persistence: commit with a wake message iff
// issues > 0, and with the ephemeral plain-COMMIT form iff only wisps woke
// (wisp tables are dolt_ignored, so their wake needs a SQL commit but must
// mint no version commit).
func (u *issueUseCaseImpl) WakeExpiredDefers(ctx context.Context) (issues, wisps int, err error) {
issues, wisps, err = u.issueRepo.WakeExpiredDefers(ctx)
if err != nil {
return 0, 0, fmt.Errorf("WakeExpiredDefers: %w", err)
}View on GitHub (pinned to 71377f2769)
Solutions
- Ensure the worker persists/propagates the issue id across its lifecycle
- Validate the id before entering the heartbeat loop
- Stop the heartbeat loop and re-claim the issue if the id is lost
Example fix
// before
for range ticker.C {
uc.Heartbeat(ctx, issueID, actor)
}
// after
for range ticker.C {
if issueID == "" {
break // lost claim; re-acquire instead
}
uc.Heartbeat(ctx, issueID, actor)
} Defensive patterns
Strategy: validation
Validate before calling
if id == "" || actor == "" {
return fmt.Errorf("heartbeat requires non-empty issue id and actor")
} Try / catch
if err := uc.Heartbeat(ctx, id, actor); err != nil {
if strings.Contains(err.Error(), "id must not be empty") {
return fmt.Errorf("worker lost issue id; stopping heartbeat")
}
return err
} Prevention
- Persist the claimed issue id so worker restarts can resume heartbeats
- Guard the heartbeat loop: skip/stop when id or actor is empty
- Only heartbeat issues your actor holds in_progress
When it happens
Trigger: Calling Heartbeat(ctx, "", actor) — typically when the id tracked by a long-running worker was lost or never set.
Common situations: Background workers whose issue-id state was reset by a restart; passing an empty field from config or a queue message into the heartbeat loop.
Related errors
- %w: %s is ephemeral
- db: IssueSQLRepository.HeartbeatIssue: %w
- Heartbeat: %w
- %w: %s is ephemeral
- no store is open for this workspace
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/4b8c8b25ecf52f4e.
Report an issue: GitHub.