gastownhall/beads · error
failed to close issue: %s
Error message
failed to close issue: %s
What it means
This error is returned when the close UPDATE matched zero rows and the follow-up check confirms the issue exists but is not in 'closed' status — i.e. the row could not be closed for a reason other than being already closed. This is a logical/state failure rather than a driver failure; the issue ID is included for direct reporting. Note: if the existing status were 'closed', the function returns AlreadyClosed success instead.
Source
Thrown at internal/storage/issueops/close.go:366
rows, err := result.RowsAffected()
if err != nil {
return nil, fmt.Errorf("failed to get rows affected: %w", err)
}
if rows == 0 {
var status string
qerr := tx.QueryRowContext(ctx,
fmt.Sprintf(`SELECT status FROM %s WHERE id = ?`, issueTable), id,
).Scan(&status)
if qerr == sql.ErrNoRows {
return nil, fmt.Errorf("%w: issue %s", storage.ErrNotFound, id)
}
if qerr != nil {
return nil, fmt.Errorf("failed to check issue existence: %w", qerr)
}
if types.Status(status) == types.StatusClosed {
return &CloseResult{IsWisp: isWisp, AlreadyClosed: true}, nil
}
return nil, fmt.Errorf("failed to close issue: %s", id)
}
// A closed issue holds no lease (no-op for wisps, which are never leased).
if err := DeleteLeaseInTx(ctx, tx, id); err != nil {
return nil, err
}
if recordEvent {
if err := RecordEventInTable(ctx, tx, eventTable, id, types.EventClosed, actor, reason); err != nil {
return nil, fmt.Errorf("failed to record event: %w", err)
}
}
recompute, err := RecomputeIsBlockedInTxWithResult(ctx, tx, affectedIssues, affectedWisps)
if err != nil {
return nil, fmt.Errorf("recompute is_blocked after close for %s: %w", id, err)
}
View on GitHub (pinned to 71377f2769)
Solutions
- Re-run the close; if transient (concurrent delete/recreate), the retry will see a consistent state.
- Check the issue's current status with `bd show <id>`; use the appropriate command for its state (e.g. wisp-specific handling).
- Serialize conflicting writers — don't close and delete the same issue concurrently.
- If a custom status value is present, correct it or upgrade beads so the close predicate covers it.
Defensive patterns
Strategy: try-catch
Validate before calling
// verify the issue is in a closable state first
var status string
if err := db.QueryRow("SELECT status FROM issues WHERE id = ?", id).Scan(&status); err != nil {
return err
}
if types.Status(status) == types.StatusClosed {
return nil // nothing to do
} Try / catch
err := store.CloseIssue(ctx, id, actor, reason)
if err != nil && strings.HasPrefix(err.Error(), "failed to close issue: ") {
// non-driver, non-notfound state conflict: inspect current status
st, _ := getStatus(ctx, id)
return fmt.Errorf("cannot close %s (status=%s)", id, st)
} Prevention
- Don't close and delete/mutate the same issue concurrently — serialize writes.
- Check current status before issuing the close; treat already-closed as success.
- Route wisp records through wisp-specific paths, not the issue close path.
- Avoid external tools writing arbitrary status values into the issues table.
When it happens
Trigger: UPDATE matched 0 rows, the issue exists with a status other than closed (e.g. a wisp status or a custom status value) so the WHERE status != 'closed' predicate excluded it, yet the code path treats it as unclosable; or a concurrent transaction deleted/re-created the row between UPDATE and SELECT.
Common situations: Racing writers: one goroutine deletes or mutates the issue while another closes it; data mutated by an external tool so the status holds an unexpected value; wisps being closed through the issue-close path instead of the wisp path.
Related errors
- step %s already claimed (status: %s)
- lock busy: held by another process
- lock already held by another process
- lock already held by another process
- errStartInterrupted
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/3be9d31c0809c4ad.
Report an issue: GitHub.