feat(doctor): add patrol pollution detection and fix
Add CheckPatrolPollution to detect stale patrol beads: - Patrol digests matching 'Digest: mol-*-patrol' - Session ended beads matching 'Session ended: *' Includes auto-fix via 'bd doctor --fix' to clean up pollution. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -612,9 +612,10 @@ func runDiagnostics(path string) doctorResult {
|
|||||||
result.Checks = append(result.Checks, staleMQFilesCheck)
|
result.Checks = append(result.Checks, staleMQFilesCheck)
|
||||||
// Don't fail overall check for legacy MQ files, just warn
|
// Don't fail overall check for legacy MQ files, just warn
|
||||||
|
|
||||||
// Note: Check 26d (misclassified wisps) was referenced but never implemented.
|
// Check 26d: Patrol pollution (patrol digests, session beads)
|
||||||
// The commit f703237c added importer-based auto-detection instead.
|
patrolPollutionCheck := convertDoctorCheck(doctor.CheckPatrolPollution(path))
|
||||||
// Removing the undefined reference to fix build.
|
result.Checks = append(result.Checks, patrolPollutionCheck)
|
||||||
|
// Don't fail overall check for patrol pollution, just warn
|
||||||
|
|
||||||
// Check 27: Expired tombstones (maintenance)
|
// Check 27: Expired tombstones (maintenance)
|
||||||
tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path))
|
tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/beads"
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
@@ -173,3 +174,100 @@ func ExpiredTombstones(path string) error {
|
|||||||
fmt.Printf(" Pruned %d expired tombstone(s) (older than %d days)\n", prunedCount, ttlDays)
|
fmt.Printf(" Pruned %d expired tombstone(s) (older than %d days)\n", prunedCount, ttlDays)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatrolPollution deletes patrol digest and session ended beads that pollute the database.
|
||||||
|
// This is the fix handler for the "Patrol Pollution" doctor check.
|
||||||
|
//
|
||||||
|
// It removes beads matching:
|
||||||
|
// - Patrol digests: titles matching "Digest: mol-*-patrol"
|
||||||
|
// - Session ended beads: titles matching "Session ended: *"
|
||||||
|
//
|
||||||
|
// After deletion, runs compact --purge-tombstones equivalent to clean up.
|
||||||
|
func PatrolPollution(path string) error {
|
||||||
|
if err := validateBeadsWorkspace(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
beadsDir := filepath.Join(path, ".beads")
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||||
|
fmt.Println(" No JSONL file found, nothing to clean up")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database path
|
||||||
|
var dbPath string
|
||||||
|
if cfg, err := configfile.Load(beadsDir); err == nil && cfg != nil && cfg.Database != "" {
|
||||||
|
dbPath = cfg.DatabasePath(beadsDir)
|
||||||
|
} else {
|
||||||
|
dbPath = filepath.Join(beadsDir, beads.CanonicalDatabaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
|
||||||
|
// Get all issues and identify pollution
|
||||||
|
issues, err := store.SearchIssues(ctx, "", types.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query issues: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var patrolDigestCount, sessionBeadCount int
|
||||||
|
var toDelete []string
|
||||||
|
|
||||||
|
for _, issue := range issues {
|
||||||
|
// Skip tombstones
|
||||||
|
if issue.DeletedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
title := issue.Title
|
||||||
|
|
||||||
|
// Check for patrol digest pattern: "Digest: mol-*-patrol"
|
||||||
|
if strings.HasPrefix(title, "Digest: mol-") && strings.HasSuffix(title, "-patrol") {
|
||||||
|
patrolDigestCount++
|
||||||
|
toDelete = append(toDelete, issue.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session ended pattern: "Session ended: *"
|
||||||
|
if strings.HasPrefix(title, "Session ended:") {
|
||||||
|
sessionBeadCount++
|
||||||
|
toDelete = append(toDelete, issue.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toDelete) == 0 {
|
||||||
|
fmt.Println(" No patrol pollution beads to delete")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all pollution beads
|
||||||
|
var deleted int
|
||||||
|
for _, id := range toDelete {
|
||||||
|
if err := store.DeleteIssue(ctx, id); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to delete %s: %v\n", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report results
|
||||||
|
if patrolDigestCount > 0 {
|
||||||
|
fmt.Printf(" Deleted %d patrol digest bead(s)\n", patrolDigestCount)
|
||||||
|
}
|
||||||
|
if sessionBeadCount > 0 {
|
||||||
|
fmt.Printf(" Deleted %d session ended bead(s)\n", sessionBeadCount)
|
||||||
|
}
|
||||||
|
fmt.Printf(" Total: %d pollution bead(s) removed\n", deleted)
|
||||||
|
|
||||||
|
// Suggest running compact to purge tombstones
|
||||||
|
fmt.Println(" 💡 Run 'bd compact --purge-tombstones' to reclaim space")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -520,3 +520,172 @@ func CheckMisclassifiedWisps(path string) DoctorCheck {
|
|||||||
Category: CategoryMaintenance,
|
Category: CategoryMaintenance,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatrolPollutionThresholds defines when to warn about patrol pollution
|
||||||
|
const (
|
||||||
|
PatrolDigestThreshold = 10 // Warn if patrol digests > 10
|
||||||
|
SessionBeadThreshold = 50 // Warn if session beads > 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// PatrolPollutionResult contains counts of detected pollution beads
|
||||||
|
type PatrolPollutionResult struct {
|
||||||
|
PatrolDigestCount int // Count of "Digest: mol-*-patrol" beads
|
||||||
|
SessionBeadCount int // Count of "Session ended: *" beads
|
||||||
|
PatrolDigestIDs []string // Sample IDs for display
|
||||||
|
SessionBeadIDs []string // Sample IDs for display
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPatrolPollution detects patrol digest and session ended beads that pollute the database.
|
||||||
|
// These beads are created during patrol operations and should not persist in the database.
|
||||||
|
//
|
||||||
|
// Patterns detected:
|
||||||
|
// - Patrol digests: titles matching "Digest: mol-*-patrol"
|
||||||
|
// - Session ended beads: titles matching "Session ended: *"
|
||||||
|
func CheckPatrolPollution(path string) DoctorCheck {
|
||||||
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
if _, err := os.Stat(jsonlPath); os.IsNotExist(err) {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Patrol Pollution",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (no JSONL file)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read JSONL and count pollution beads
|
||||||
|
file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely
|
||||||
|
if err != nil {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Patrol Pollution",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "N/A (unable to read JSONL)",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
result := detectPatrolPollution(file)
|
||||||
|
|
||||||
|
// Check thresholds
|
||||||
|
hasPatrolPollution := result.PatrolDigestCount > PatrolDigestThreshold
|
||||||
|
hasSessionPollution := result.SessionBeadCount > SessionBeadThreshold
|
||||||
|
|
||||||
|
if !hasPatrolPollution && !hasSessionPollution {
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Patrol Pollution",
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "No patrol pollution detected",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build warning message
|
||||||
|
var warnings []string
|
||||||
|
if hasPatrolPollution {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%d patrol digest beads (should be 0)", result.PatrolDigestCount))
|
||||||
|
}
|
||||||
|
if hasSessionPollution {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%d session ended beads (should be wisps)", result.SessionBeadCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build detail with sample IDs
|
||||||
|
var details []string
|
||||||
|
if len(result.PatrolDigestIDs) > 0 {
|
||||||
|
details = append(details, fmt.Sprintf("Patrol digests: %v", result.PatrolDigestIDs))
|
||||||
|
}
|
||||||
|
if len(result.SessionBeadIDs) > 0 {
|
||||||
|
details = append(details, fmt.Sprintf("Session beads: %v", result.SessionBeadIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return DoctorCheck{
|
||||||
|
Name: "Patrol Pollution",
|
||||||
|
Status: StatusWarning,
|
||||||
|
Message: strings.Join(warnings, ", "),
|
||||||
|
Detail: strings.Join(details, "; "),
|
||||||
|
Fix: "Run 'bd doctor --fix' to clean up patrol pollution",
|
||||||
|
Category: CategoryMaintenance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPatrolPollution scans a JSONL file for patrol pollution patterns
|
||||||
|
func detectPatrolPollution(file *os.File) PatrolPollutionResult {
|
||||||
|
var result PatrolPollutionResult
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tombstones
|
||||||
|
if issue.DeletedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
title := issue.Title
|
||||||
|
|
||||||
|
// Check for patrol digest pattern: "Digest: mol-*-patrol"
|
||||||
|
if strings.HasPrefix(title, "Digest: mol-") && strings.HasSuffix(title, "-patrol") {
|
||||||
|
result.PatrolDigestCount++
|
||||||
|
if len(result.PatrolDigestIDs) < 3 {
|
||||||
|
result.PatrolDigestIDs = append(result.PatrolDigestIDs, issue.ID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session ended pattern: "Session ended: *"
|
||||||
|
if strings.HasPrefix(title, "Session ended:") {
|
||||||
|
result.SessionBeadCount++
|
||||||
|
if len(result.SessionBeadIDs) < 3 {
|
||||||
|
result.SessionBeadIDs = append(result.SessionBeadIDs, issue.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPatrolPollutionIDs returns all IDs of patrol pollution beads for deletion
|
||||||
|
func GetPatrolPollutionIDs(path string) ([]string, error) {
|
||||||
|
beadsDir := resolveBeadsDir(filepath.Join(path, ".beads"))
|
||||||
|
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||||
|
|
||||||
|
file, err := os.Open(jsonlPath) // #nosec G304 - path constructed safely
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open issues.jsonl: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
decoder := json.NewDecoder(file)
|
||||||
|
|
||||||
|
for {
|
||||||
|
var issue types.Issue
|
||||||
|
if err := decoder.Decode(&issue); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tombstones
|
||||||
|
if issue.DeletedAt != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
title := issue.Title
|
||||||
|
|
||||||
|
// Check for patrol digest pattern
|
||||||
|
if strings.HasPrefix(title, "Digest: mol-") && strings.HasSuffix(title, "-patrol") {
|
||||||
|
ids = append(ids, issue.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session ended pattern
|
||||||
|
if strings.HasPrefix(title, "Session ended:") {
|
||||||
|
ids = append(ids, issue.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ func applyFixList(path string, fixes []doctorCheck) {
|
|||||||
continue
|
continue
|
||||||
case "Legacy MQ Files":
|
case "Legacy MQ Files":
|
||||||
err = doctor.FixStaleMQFiles(path)
|
err = doctor.FixStaleMQFiles(path)
|
||||||
|
case "Patrol Pollution":
|
||||||
|
err = fix.PatrolPollution(path)
|
||||||
default:
|
default:
|
||||||
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name)
|
||||||
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
fmt.Printf(" Manual fix: %s\n", check.Fix)
|
||||||
|
|||||||
Reference in New Issue
Block a user