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

  1. Ensure the worker persists/propagates the issue id across its lifecycle
  2. Validate the id before entering the heartbeat loop
  3. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/4b8c8b25ecf52f4e. Report an issue: GitHub.