Remove obsolete renumber and stale commands, fix test
- Remove renumber.go: Hash IDs eliminated need for ID compaction - Remove stale.go: Executor/heartbeat tables were never implemented - Fix TestListCommand: duplicate dependency constraint violation - Update comments removing references to removed commands Amp-Thread-ID: https://ampcode.com/threads/T-3dcd8681-c7d3-4fe1-9750-b38279b56cdb Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -528,12 +528,12 @@ func writeJSONLAtomic(jsonlPath string, issues []*types.Issue) ([]string, error)
|
|||||||
// flushToJSONL exports dirty issues to JSONL using incremental updates
|
// flushToJSONL exports dirty issues to JSONL using incremental updates
|
||||||
// flushToJSONL exports dirty database changes to the JSONL file. Uses incremental
|
// flushToJSONL exports dirty database changes to the JSONL file. Uses incremental
|
||||||
// export by default (only exports modified issues), or full export for ID-changing
|
// export by default (only exports modified issues), or full export for ID-changing
|
||||||
// operations (renumber, resolve-collisions). Invoked by the debounce timer or
|
// operations (e.g., rename-prefix). Invoked by the debounce timer or
|
||||||
// immediately on command exit.
|
// immediately on command exit.
|
||||||
//
|
//
|
||||||
// Export modes:
|
// Export modes:
|
||||||
// - Incremental (default): Exports only GetDirtyIssues(), merges with existing JSONL
|
// - Incremental (default): Exports only GetDirtyIssues(), merges with existing JSONL
|
||||||
// - Full (after renumber): Exports all issues, rebuilds JSONL from scratch
|
// - Full (after rename-prefix): Exports all issues, rebuilds JSONL from scratch
|
||||||
//
|
//
|
||||||
// Error handling: Tracks consecutive failures. After 3+ failures, displays prominent
|
// Error handling: Tracks consecutive failures. After 3+ failures, displays prominent
|
||||||
// warning suggesting manual "bd export" to recover. Failure counter resets on success.
|
// warning suggesting manual "bd export" to recover. Failure counter resets on success.
|
||||||
|
|||||||
@@ -200,16 +200,7 @@ func TestListCommand(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("output formatted list digraph preset", func(t *testing.T) {
|
t.Run("output formatted list digraph preset", func(t *testing.T) {
|
||||||
// Add a dependency first
|
// Dependency already added in previous test, just use it
|
||||||
dep := &types.Dependency{
|
|
||||||
IssueID: h.issues[0].ID,
|
|
||||||
DependsOnID: h.issues[1].ID,
|
|
||||||
Type: types.DepBlocks,
|
|
||||||
}
|
|
||||||
if err := h.store.AddDependency(h.ctx, dep, "test-user"); err != nil {
|
|
||||||
t.Fatalf("Failed to add dependency: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := outputFormattedList(h.ctx, h.store, h.issues, "digraph")
|
err := outputFormattedList(h.ctx, h.store, h.issues, "digraph")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("outputFormattedList with digraph format failed: %v", err)
|
t.Errorf("outputFormattedList with digraph format failed: %v", err)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ var (
|
|||||||
// Auto-flush state
|
// Auto-flush state
|
||||||
autoFlushEnabled = true // Can be disabled with --no-auto-flush
|
autoFlushEnabled = true // Can be disabled with --no-auto-flush
|
||||||
isDirty = false // Tracks if DB has changes needing export
|
isDirty = false // Tracks if DB has changes needing export
|
||||||
needsFullExport = false // Set to true when IDs change (renumber, rename-prefix)
|
needsFullExport = false // Set to true when IDs change (e.g., rename-prefix)
|
||||||
flushMutex sync.Mutex
|
flushMutex sync.Mutex
|
||||||
flushTimer *time.Timer
|
flushTimer *time.Timer
|
||||||
storeMutex sync.Mutex // Protects store access from background goroutine
|
storeMutex sync.Mutex // Protects store access from background goroutine
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
||||||
"github.com/steveyegge/beads/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
var renumberCmd = &cobra.Command{
|
|
||||||
Use: "renumber",
|
|
||||||
Short: "Renumber all issues to compact the ID space",
|
|
||||||
Long: `Renumber all issues sequentially to eliminate gaps in the ID space.
|
|
||||||
|
|
||||||
This command will:
|
|
||||||
- Renumber all issues starting from 1 (keeping chronological order)
|
|
||||||
- Update all dependency links (blocks, related, parent-child, discovered-from)
|
|
||||||
- Update all text references in descriptions, notes, acceptance criteria
|
|
||||||
- Show a mapping report of old ID -> new ID
|
|
||||||
- Export the updated database to JSONL
|
|
||||||
|
|
||||||
Example:
|
|
||||||
bd renumber --dry-run # Preview changes
|
|
||||||
bd renumber --force # Actually renumber
|
|
||||||
|
|
||||||
Risks:
|
|
||||||
- May break external references in GitHub issues, docs, commits
|
|
||||||
- Git history may become confusing
|
|
||||||
- Operation cannot be undone (backup recommended)`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
|
||||||
|
|
||||||
if !dryRun && !force {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: must specify --dry-run or --force\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renumber command needs direct access to storage
|
|
||||||
// Ensure we have a direct store connection
|
|
||||||
if store == nil {
|
|
||||||
var err error
|
|
||||||
if dbPath == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: no database path found\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
store, err = sqlite.New(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer func() { _ = store.Close() }()
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Get prefix from config, or derive from first issue if not set
|
|
||||||
prefix, err := store.GetConfig(ctx, "issue_prefix")
|
|
||||||
if err != nil || prefix == "" {
|
|
||||||
// Get any issue to derive prefix
|
|
||||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
||||||
if err != nil || len(issues) == 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: failed to determine issue prefix\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
// Extract prefix from first issue (e.g., "bd-123" -> "bd")
|
|
||||||
parts := strings.Split(issues[0].ID, "-")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: invalid issue ID format: %s\n", issues[0].ID)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
prefix = parts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all issues sorted by creation time
|
|
||||||
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: failed to list issues: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) == 0 {
|
|
||||||
fmt.Println("No issues to renumber")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by creation time to preserve chronological order
|
|
||||||
sort.Slice(issues, func(i, j int) bool {
|
|
||||||
return issues[i].CreatedAt.Before(issues[j].CreatedAt)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Build mapping from old ID to new ID
|
|
||||||
idMapping := make(map[string]string)
|
|
||||||
for i, issue := range issues {
|
|
||||||
newNum := i + 1
|
|
||||||
newID := fmt.Sprintf("%s-%d", prefix, newNum)
|
|
||||||
idMapping[issue.ID] = newID
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun {
|
|
||||||
cyan := color.New(color.FgCyan).SprintFunc()
|
|
||||||
fmt.Printf("DRY RUN: Would renumber %d issues\n\n", len(issues))
|
|
||||||
fmt.Printf("Sample changes:\n")
|
|
||||||
changesShown := 0
|
|
||||||
for _, issue := range issues {
|
|
||||||
oldID := issue.ID
|
|
||||||
newID := idMapping[oldID]
|
|
||||||
if oldID != newID {
|
|
||||||
fmt.Printf(" %s -> %s (%s)\n", cyan(oldID), cyan(newID), issue.Title)
|
|
||||||
changesShown++
|
|
||||||
if changesShown >= 10 {
|
|
||||||
skipped := 0
|
|
||||||
for _, iss := range issues {
|
|
||||||
if iss.ID != idMapping[iss.ID] {
|
|
||||||
skipped++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
skipped -= changesShown
|
|
||||||
if skipped > 0 {
|
|
||||||
fmt.Printf("... and %d more changes\n", skipped)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
|
||||||
|
|
||||||
fmt.Printf("Renumbering %d issues...\n", len(issues))
|
|
||||||
|
|
||||||
if err := renumberIssuesInDB(ctx, prefix, idMapping, issues); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: failed to renumber issues: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule full export (IDs changed, incremental won't work)
|
|
||||||
markDirtyAndScheduleFullExport()
|
|
||||||
|
|
||||||
fmt.Printf("%s Successfully renumbered %d issues\n", green("✓"), len(issues))
|
|
||||||
|
|
||||||
// Count actual changes
|
|
||||||
changed := 0
|
|
||||||
for oldID, newID := range idMapping {
|
|
||||||
if oldID != newID {
|
|
||||||
changed++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" %d issues renumbered, %d unchanged\n", changed, len(issues)-changed)
|
|
||||||
|
|
||||||
if jsonOutput {
|
|
||||||
result := map[string]interface{}{
|
|
||||||
"total_issues": len(issues),
|
|
||||||
"changed": changed,
|
|
||||||
"unchanged": len(issues) - changed,
|
|
||||||
}
|
|
||||||
enc := json.NewEncoder(os.Stdout)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
_ = enc.Encode(result)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func renumberIssuesInDB(ctx context.Context, prefix string, idMapping map[string]string, issues []*types.Issue) error {
|
|
||||||
// Step 0: Get all dependencies BEFORE renaming (while IDs still match)
|
|
||||||
allDepsByIssue, err := store.GetAllDependencyRecords(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get dependency records: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Rename all issues to temporary UUIDs to avoid collisions
|
|
||||||
tempMapping := make(map[string]string)
|
|
||||||
|
|
||||||
for _, issue := range issues {
|
|
||||||
oldID := issue.ID
|
|
||||||
// Use UUID to guarantee uniqueness (no collision possible)
|
|
||||||
tempID := fmt.Sprintf("temp-%s", uuid.New().String())
|
|
||||||
tempMapping[oldID] = tempID
|
|
||||||
|
|
||||||
// Rename to temp ID (don't update text yet)
|
|
||||||
issue.ID = tempID
|
|
||||||
if err := store.UpdateIssueID(ctx, oldID, tempID, issue, actor); err != nil {
|
|
||||||
return fmt.Errorf("failed to rename %s to temp ID: %w", oldID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Rename from temp IDs to final IDs (still don't update text)
|
|
||||||
for _, issue := range issues {
|
|
||||||
tempID := issue.ID // Currently has temp ID
|
|
||||||
|
|
||||||
// Find original ID
|
|
||||||
var oldOriginalID string
|
|
||||||
for origID, tID := range tempMapping {
|
|
||||||
if tID == tempID {
|
|
||||||
oldOriginalID = origID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finalID := idMapping[oldOriginalID]
|
|
||||||
|
|
||||||
// Just update the ID, not text yet
|
|
||||||
issue.ID = finalID
|
|
||||||
if err := store.UpdateIssueID(ctx, tempID, finalID, issue, actor); err != nil {
|
|
||||||
return fmt.Errorf("failed to update issue %s: %w", tempID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Now update all text references using the original old->new mapping
|
|
||||||
// Build regex to match any OLD issue ID (before renumbering)
|
|
||||||
oldIDs := make([]string, 0, len(idMapping))
|
|
||||||
for oldID := range idMapping {
|
|
||||||
oldIDs = append(oldIDs, regexp.QuoteMeta(oldID))
|
|
||||||
}
|
|
||||||
oldPattern := regexp.MustCompile(`\b(` + strings.Join(oldIDs, "|") + `)\b`)
|
|
||||||
|
|
||||||
replaceFunc := func(match string) string {
|
|
||||||
if newID, ok := idMapping[match]; ok {
|
|
||||||
return newID
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update text references in all issues
|
|
||||||
for _, issue := range issues {
|
|
||||||
changed := false
|
|
||||||
|
|
||||||
newTitle := oldPattern.ReplaceAllStringFunc(issue.Title, replaceFunc)
|
|
||||||
if newTitle != issue.Title {
|
|
||||||
issue.Title = newTitle
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
newDesc := oldPattern.ReplaceAllStringFunc(issue.Description, replaceFunc)
|
|
||||||
if newDesc != issue.Description {
|
|
||||||
issue.Description = newDesc
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if issue.Design != "" {
|
|
||||||
newDesign := oldPattern.ReplaceAllStringFunc(issue.Design, replaceFunc)
|
|
||||||
if newDesign != issue.Design {
|
|
||||||
issue.Design = newDesign
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if issue.AcceptanceCriteria != "" {
|
|
||||||
newAC := oldPattern.ReplaceAllStringFunc(issue.AcceptanceCriteria, replaceFunc)
|
|
||||||
if newAC != issue.AcceptanceCriteria {
|
|
||||||
issue.AcceptanceCriteria = newAC
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if issue.Notes != "" {
|
|
||||||
newNotes := oldPattern.ReplaceAllStringFunc(issue.Notes, replaceFunc)
|
|
||||||
if newNotes != issue.Notes {
|
|
||||||
issue.Notes = newNotes
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update if text changed
|
|
||||||
if changed {
|
|
||||||
if err := store.UpdateIssue(ctx, issue.ID, map[string]interface{}{
|
|
||||||
"title": issue.Title,
|
|
||||||
"description": issue.Description,
|
|
||||||
"design": issue.Design,
|
|
||||||
"acceptance_criteria": issue.AcceptanceCriteria,
|
|
||||||
"notes": issue.Notes,
|
|
||||||
}, actor); err != nil {
|
|
||||||
return fmt.Errorf("failed to update text references in %s: %w", issue.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update all dependency links (use the deps we fetched before renaming)
|
|
||||||
if err := renumberDependencies(ctx, idMapping, allDepsByIssue); err != nil {
|
|
||||||
return fmt.Errorf("failed to update dependencies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the counter to the highest renumbered ID so next issue gets correct number
|
|
||||||
// REMOVED (bd-c7af): Counter sync after renumbering - no longer needed with hash IDs
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func renumberDependencies(ctx context.Context, idMapping map[string]string, allDepsByIssue map[string][]*types.Dependency) error {
|
|
||||||
// Collect all dependencies to update
|
|
||||||
oldDeps := make([]*types.Dependency, 0)
|
|
||||||
newDeps := make([]*types.Dependency, 0)
|
|
||||||
|
|
||||||
for issueID, deps := range allDepsByIssue {
|
|
||||||
newIssueID, issueRenamed := idMapping[issueID]
|
|
||||||
if !issueRenamed {
|
|
||||||
newIssueID = issueID
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dep := range deps {
|
|
||||||
newDependsOnID, depRenamed := idMapping[dep.DependsOnID]
|
|
||||||
if !depRenamed {
|
|
||||||
newDependsOnID = dep.DependsOnID
|
|
||||||
}
|
|
||||||
|
|
||||||
// If either ID changed, we need to update
|
|
||||||
if issueRenamed || depRenamed {
|
|
||||||
oldDeps = append(oldDeps, dep)
|
|
||||||
newDep := &types.Dependency{
|
|
||||||
IssueID: newIssueID,
|
|
||||||
DependsOnID: newDependsOnID,
|
|
||||||
Type: dep.Type,
|
|
||||||
}
|
|
||||||
newDeps = append(newDeps, newDep)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First remove all old dependencies
|
|
||||||
for _, oldDep := range oldDeps {
|
|
||||||
// Remove old dependency (may not exist if IDs already updated)
|
|
||||||
_ = store.RemoveDependency(ctx, oldDep.IssueID, oldDep.DependsOnID, "renumber")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then add all new dependencies
|
|
||||||
for _, newDep := range newDeps {
|
|
||||||
// Add new dependency
|
|
||||||
if err := store.AddDependency(ctx, newDep, "renumber"); err != nil {
|
|
||||||
// Ignore duplicate and validation errors (parent-child direction might be swapped)
|
|
||||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") &&
|
|
||||||
!strings.Contains(err.Error(), "duplicate") &&
|
|
||||||
!strings.Contains(err.Error(), "invalid parent-child") {
|
|
||||||
return fmt.Errorf("failed to add dependency %s -> %s: %w", newDep.IssueID, newDep.DependsOnID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
renumberCmd.Flags().Bool("dry-run", false, "Preview changes without applying them")
|
|
||||||
renumberCmd.Flags().Bool("force", false, "Actually perform the renumbering")
|
|
||||||
rootCmd.AddCommand(renumberCmd)
|
|
||||||
}
|
|
||||||
300
cmd/bd/stale.go
300
cmd/bd/stale.go
@@ -1,300 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StaleIssueInfo contains information about an orphaned issue claim
|
|
||||||
type StaleIssueInfo struct {
|
|
||||||
IssueID string `json:"issue_id"`
|
|
||||||
IssueTitle string `json:"issue_title"`
|
|
||||||
IssuePriority int `json:"issue_priority"`
|
|
||||||
ExecutorInstanceID string `json:"executor_instance_id"`
|
|
||||||
ExecutorStatus string `json:"executor_status"`
|
|
||||||
ExecutorHostname string `json:"executor_hostname"`
|
|
||||||
ExecutorPID int `json:"executor_pid"`
|
|
||||||
LastHeartbeat time.Time `json:"last_heartbeat"`
|
|
||||||
ClaimedAt time.Time `json:"claimed_at"`
|
|
||||||
ClaimedDuration string `json:"claimed_duration"` // Human-readable duration
|
|
||||||
}
|
|
||||||
|
|
||||||
var staleCmd = &cobra.Command{
|
|
||||||
Use: "stale",
|
|
||||||
Short: "Show orphaned claims and dead executors",
|
|
||||||
Long: `Show issues stuck in_progress with execution_state where the executor is dead or stopped.
|
|
||||||
This helps identify orphaned work that needs manual recovery.
|
|
||||||
|
|
||||||
An issue is considered stale if:
|
|
||||||
- It has an execution_state (claimed by an executor)
|
|
||||||
- AND the executor status is 'stopped'
|
|
||||||
- OR the executor's last_heartbeat is older than the threshold
|
|
||||||
|
|
||||||
Default threshold: 300 seconds (5 minutes)`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
threshold, _ := cmd.Flags().GetInt("threshold")
|
|
||||||
release, _ := cmd.Flags().GetBool("release")
|
|
||||||
|
|
||||||
// Get stale issues
|
|
||||||
staleIssues, err := getStaleIssues(threshold)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle JSON output
|
|
||||||
if jsonOutput {
|
|
||||||
if staleIssues == nil {
|
|
||||||
staleIssues = []*StaleIssueInfo{}
|
|
||||||
}
|
|
||||||
outputJSON(staleIssues)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty result
|
|
||||||
if len(staleIssues) == 0 {
|
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
|
||||||
fmt.Printf("\n%s No stale issues found (all executors healthy)\n\n", green("✨"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display stale issues
|
|
||||||
red := color.New(color.FgRed).SprintFunc()
|
|
||||||
yellow := color.New(color.FgYellow).SprintFunc()
|
|
||||||
fmt.Printf("\n%s Found %d stale issue(s) with orphaned claims:\n\n", yellow("⚠️"), len(staleIssues))
|
|
||||||
|
|
||||||
for i, si := range staleIssues {
|
|
||||||
fmt.Printf("%d. [P%d] %s: %s\n", i+1, si.IssuePriority, si.IssueID, si.IssueTitle)
|
|
||||||
fmt.Printf(" Executor: %s (%s)\n", si.ExecutorInstanceID, si.ExecutorStatus)
|
|
||||||
fmt.Printf(" Host: %s (PID: %d)\n", si.ExecutorHostname, si.ExecutorPID)
|
|
||||||
fmt.Printf(" Last heartbeat: %s (%.0f seconds ago)\n",
|
|
||||||
si.LastHeartbeat.Format("2006-01-02 15:04:05"),
|
|
||||||
time.Since(si.LastHeartbeat).Seconds())
|
|
||||||
fmt.Printf(" Claimed for: %s\n", si.ClaimedDuration)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle release flag
|
|
||||||
if release {
|
|
||||||
fmt.Printf("%s Releasing %d stale issue(s)...\n\n", yellow("🔧"), len(staleIssues))
|
|
||||||
|
|
||||||
releaseCount, err := releaseStaleIssues(staleIssues)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s Failed to release issues: %v\n", red("✗"), err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
green := color.New(color.FgGreen).SprintFunc()
|
|
||||||
fmt.Printf("%s Successfully released %d issue(s) and marked executors as stopped\n\n", green("✓"), releaseCount)
|
|
||||||
|
|
||||||
// Schedule auto-flush if any issues were released
|
|
||||||
if releaseCount > 0 {
|
|
||||||
markDirtyAndScheduleFlush()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cyan := color.New(color.FgCyan).SprintFunc()
|
|
||||||
fmt.Printf("%s Use --release flag to automatically release these issues\n\n", cyan("💡"))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStaleIssues queries for issues with execution_state where executor is dead/stopped
|
|
||||||
func getStaleIssues(thresholdSeconds int) ([]*StaleIssueInfo, error) {
|
|
||||||
// Ensure we have a direct store when daemon lacks stale support
|
|
||||||
if daemonClient != nil {
|
|
||||||
if err := ensureDirectMode("daemon does not support stale command"); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
||||||
}
|
|
||||||
} else if store == nil {
|
|
||||||
if err := ensureStoreActive(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
cutoffTime := time.Now().Add(-time.Duration(thresholdSeconds) * time.Second)
|
|
||||||
|
|
||||||
// Query for stale issues
|
|
||||||
// Use LEFT JOIN to catch orphaned execution states where executor instance is missing
|
|
||||||
query := `
|
|
||||||
SELECT
|
|
||||||
i.id,
|
|
||||||
i.title,
|
|
||||||
i.priority,
|
|
||||||
ies.executor_instance_id,
|
|
||||||
COALESCE(ei.status, 'missing'),
|
|
||||||
COALESCE(ei.hostname, 'unknown'),
|
|
||||||
COALESCE(ei.pid, 0),
|
|
||||||
ei.last_heartbeat,
|
|
||||||
ies.started_at
|
|
||||||
FROM issues i
|
|
||||||
JOIN issue_execution_state ies ON i.id = ies.issue_id
|
|
||||||
LEFT JOIN executor_instances ei ON ies.executor_instance_id = ei.instance_id
|
|
||||||
WHERE ei.instance_id IS NULL
|
|
||||||
OR ei.status = 'stopped'
|
|
||||||
OR ei.last_heartbeat < ?
|
|
||||||
ORDER BY ei.last_heartbeat ASC, i.priority ASC
|
|
||||||
`
|
|
||||||
|
|
||||||
// Access the underlying SQLite connection
|
|
||||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("stale command requires SQLite backend")
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := sqliteStore.QueryContext(ctx, query, cutoffTime)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query stale issues: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = rows.Close() }()
|
|
||||||
|
|
||||||
var staleIssues []*StaleIssueInfo
|
|
||||||
for rows.Next() {
|
|
||||||
var si StaleIssueInfo
|
|
||||||
var lastHeartbeat sql.NullTime
|
|
||||||
err := rows.Scan(
|
|
||||||
&si.IssueID,
|
|
||||||
&si.IssueTitle,
|
|
||||||
&si.IssuePriority,
|
|
||||||
&si.ExecutorInstanceID,
|
|
||||||
&si.ExecutorStatus,
|
|
||||||
&si.ExecutorHostname,
|
|
||||||
&si.ExecutorPID,
|
|
||||||
&lastHeartbeat,
|
|
||||||
&si.ClaimedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan stale issue: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle nullable last_heartbeat
|
|
||||||
if lastHeartbeat.Valid {
|
|
||||||
si.LastHeartbeat = lastHeartbeat.Time
|
|
||||||
} else {
|
|
||||||
// Use Unix epoch for missing executors
|
|
||||||
si.LastHeartbeat = time.Unix(0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate claimed duration
|
|
||||||
si.ClaimedDuration = formatDuration(time.Since(si.ClaimedAt))
|
|
||||||
|
|
||||||
staleIssues = append(staleIssues, &si)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("error iterating stale issues: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return staleIssues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseStaleIssues releases all stale issues by deleting execution state and resetting status
|
|
||||||
func releaseStaleIssues(staleIssues []*StaleIssueInfo) (int, error) {
|
|
||||||
// Ensure we have a direct store when daemon lacks stale support
|
|
||||||
if daemonClient != nil {
|
|
||||||
if err := ensureDirectMode("daemon does not support stale command"); err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to open database: %w", err)
|
|
||||||
}
|
|
||||||
} else if store == nil {
|
|
||||||
if err := ensureStoreActive(); err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to open database: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Access the underlying SQLite connection for transaction
|
|
||||||
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("stale command requires SQLite backend")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start transaction for atomic cleanup
|
|
||||||
tx, err := sqliteStore.BeginTx(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to begin transaction: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
releaseCount := 0
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
for _, si := range staleIssues {
|
|
||||||
// Delete execution state
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
DELETE FROM issue_execution_state
|
|
||||||
WHERE issue_id = ?
|
|
||||||
`, si.IssueID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to delete execution state for issue %s: %w", si.IssueID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset issue status to 'open'
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
UPDATE issues
|
|
||||||
SET status = 'open', updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`, now, si.IssueID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to reset issue status for %s: %w", si.IssueID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add comment explaining the release
|
|
||||||
comment := fmt.Sprintf("Issue automatically released - executor instance %s became stale (last heartbeat: %s)",
|
|
||||||
si.ExecutorInstanceID, si.LastHeartbeat.Format("2006-01-02 15:04:05"))
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
INSERT INTO events (issue_id, event_type, actor, comment, created_at)
|
|
||||||
VALUES (?, 'status_changed', 'system', ?, ?)
|
|
||||||
`, si.IssueID, comment, now)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to add release comment for issue %s: %w", si.IssueID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark executor instance as 'stopped' if not already
|
|
||||||
_, err = tx.ExecContext(ctx, `
|
|
||||||
UPDATE executor_instances
|
|
||||||
SET status = 'stopped'
|
|
||||||
WHERE instance_id = ? AND status != 'stopped'
|
|
||||||
`, si.ExecutorInstanceID)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to mark executor as stopped: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
releaseCount++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit the transaction
|
|
||||||
if err = tx.Commit(); err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to commit transaction: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return releaseCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDuration formats a duration in a human-readable way
|
|
||||||
func formatDuration(d time.Duration) string {
|
|
||||||
if d < time.Minute {
|
|
||||||
return fmt.Sprintf("%.0f seconds", d.Seconds())
|
|
||||||
}
|
|
||||||
if d < time.Hour {
|
|
||||||
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
return fmt.Sprintf("%.1f hours", d.Hours())
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f days", d.Hours()/24)
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
staleCmd.Flags().IntP("threshold", "t", 300, "Heartbeat threshold in seconds (default: 300 = 5 minutes)")
|
|
||||||
staleCmd.Flags().BoolP("release", "r", false, "Automatically release all stale issues")
|
|
||||||
|
|
||||||
rootCmd.AddCommand(staleCmd)
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFormatDuration(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
duration time.Duration
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "less than a minute",
|
|
||||||
duration: 45 * time.Second,
|
|
||||||
want: "45 seconds",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exactly one minute",
|
|
||||||
duration: 60 * time.Second,
|
|
||||||
want: "1 minutes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "several minutes",
|
|
||||||
duration: 5 * time.Minute,
|
|
||||||
want: "5 minutes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "one hour",
|
|
||||||
duration: 60 * time.Minute,
|
|
||||||
want: "1.0 hours",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "several hours",
|
|
||||||
duration: 3*time.Hour + 30*time.Minute,
|
|
||||||
want: "3.5 hours",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "one day",
|
|
||||||
duration: 24 * time.Hour,
|
|
||||||
want: "1.0 days",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple days",
|
|
||||||
duration: 3*24*time.Hour + 12*time.Hour,
|
|
||||||
want: "3.5 days",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := formatDuration(tt.duration)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("formatDuration(%v) = %q, want %q", tt.duration, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStaleIssueInfo(t *testing.T) {
|
|
||||||
// Test that StaleIssueInfo struct can be created and serialized
|
|
||||||
info := &StaleIssueInfo{
|
|
||||||
IssueID: "bd-42",
|
|
||||||
IssueTitle: "Test Issue",
|
|
||||||
IssuePriority: 1,
|
|
||||||
ExecutorInstanceID: "exec-123",
|
|
||||||
ExecutorStatus: "stopped",
|
|
||||||
ExecutorHostname: "localhost",
|
|
||||||
ExecutorPID: 12345,
|
|
||||||
LastHeartbeat: time.Now().Add(-10 * time.Minute),
|
|
||||||
ClaimedAt: time.Now().Add(-30 * time.Minute),
|
|
||||||
ClaimedDuration: "30 minutes",
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.IssueID != "bd-42" {
|
|
||||||
t.Errorf("Expected IssueID bd-42, got %s", info.IssueID)
|
|
||||||
}
|
|
||||||
if info.ExecutorStatus != "stopped" {
|
|
||||||
t.Errorf("Expected ExecutorStatus stopped, got %s", info.ExecutorStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
description: Show orphaned claims and dead executors
|
|
||||||
argument-hint: [--release] [--threshold]
|
|
||||||
---
|
|
||||||
|
|
||||||
Show issues stuck in_progress with execution_state where the executor is dead or stopped.
|
|
||||||
|
|
||||||
Helps identify orphaned work that needs manual recovery.
|
|
||||||
|
|
||||||
## Stale Detection
|
|
||||||
|
|
||||||
An issue is stale if:
|
|
||||||
- It has an execution_state (claimed by an executor)
|
|
||||||
- AND the executor status is 'stopped'
|
|
||||||
- OR the executor's last_heartbeat is older than threshold
|
|
||||||
|
|
||||||
Default threshold: 300 seconds (5 minutes)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
- **List stale issues**: `bd stale`
|
|
||||||
- **Custom threshold**: `bd stale --threshold 600` (10 minutes)
|
|
||||||
- **Auto-release**: `bd stale --release` (automatically release all stale issues)
|
|
||||||
|
|
||||||
Useful for parallel execution systems where workers may crash or get stopped.
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// QueryContext exposes the underlying database QueryContext method for advanced queries
|
// QueryContext exposes the underlying database QueryContext method for advanced queries
|
||||||
// This is used by commands that need direct SQL access (e.g., bd stale)
|
|
||||||
func (s *SQLiteStorage) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
func (s *SQLiteStorage) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
|
||||||
return s.db.QueryContext(ctx, query, args...)
|
return s.db.QueryContext(ctx, query, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user