From 4a0f4abc709f66a0c392f90c251f0176b04ec545 Mon Sep 17 00:00:00 2001 From: emma Date: Tue, 20 Jan 2026 23:15:37 -0800 Subject: [PATCH] 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 --- cmd/bd/doctor.go | 7 +- cmd/bd/doctor/fix/maintenance.go | 98 ++++++++++++++++++ cmd/bd/doctor/maintenance.go | 169 +++++++++++++++++++++++++++++++ cmd/bd/doctor_fix.go | 2 + 4 files changed, 273 insertions(+), 3 deletions(-) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index db7529af..781ef1a7 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -612,9 +612,10 @@ func runDiagnostics(path string) doctorResult { result.Checks = append(result.Checks, staleMQFilesCheck) // Don't fail overall check for legacy MQ files, just warn - // Note: Check 26d (misclassified wisps) was referenced but never implemented. - // The commit f703237c added importer-based auto-detection instead. - // Removing the undefined reference to fix build. + // Check 26d: Patrol pollution (patrol digests, session beads) + patrolPollutionCheck := convertDoctorCheck(doctor.CheckPatrolPollution(path)) + result.Checks = append(result.Checks, patrolPollutionCheck) + // Don't fail overall check for patrol pollution, just warn // Check 27: Expired tombstones (maintenance) tombstonesExpiredCheck := convertDoctorCheck(doctor.CheckExpiredTombstones(path)) diff --git a/cmd/bd/doctor/fix/maintenance.go b/cmd/bd/doctor/fix/maintenance.go index 7eb08156..89c24efe 100644 --- a/cmd/bd/doctor/fix/maintenance.go +++ b/cmd/bd/doctor/fix/maintenance.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "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) 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 +} diff --git a/cmd/bd/doctor/maintenance.go b/cmd/bd/doctor/maintenance.go index 9badae28..43f87f28 100644 --- a/cmd/bd/doctor/maintenance.go +++ b/cmd/bd/doctor/maintenance.go @@ -520,3 +520,172 @@ func CheckMisclassifiedWisps(path string) DoctorCheck { 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 +} diff --git a/cmd/bd/doctor_fix.go b/cmd/bd/doctor_fix.go index 4e888188..514c46cf 100644 --- a/cmd/bd/doctor_fix.go +++ b/cmd/bd/doctor_fix.go @@ -312,6 +312,8 @@ func applyFixList(path string, fixes []doctorCheck) { continue case "Legacy MQ Files": err = doctor.FixStaleMQFiles(path) + case "Patrol Pollution": + err = fix.PatrolPollution(path) default: fmt.Printf(" ⚠ No automatic fix available for %s\n", check.Name) fmt.Printf(" Manual fix: %s\n", check.Fix)