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

  1. Re-run the close; if transient (concurrent delete/recreate), the retry will see a consistent state.
  2. Check the issue's current status with `bd show <id>`; use the appropriate command for its state (e.g. wisp-specific handling).
  3. Serialize conflicting writers — don't close and delete the same issue concurrently.
  4. 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

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


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