From 09355eee8c0ab3e6ab7746b38cb7d75dd2ab158b Mon Sep 17 00:00:00 2001 From: Roland Tritsch Date: Tue, 20 Jan 2026 22:06:53 +0000 Subject: [PATCH] Add --gastown flag to bd doctor for gastown-specific checks (#1162) When running in gastown multi-workspace mode, two checks produce false positives that are expected behavior: 1. routes.jsonl is a valid configuration file (maps issue prefixes to rig directories), not a duplicate JSONL file 2. Duplicate issues are expected (ephemeral wisps from patrol cycles) and normal up to ~1000, with GC cleaning them up automatically This commit adds flags to bd doctor for gastown-specific checks: - --gastown: Skip routes.jsonl warning and enable duplicate threshold - --gastown-duplicates-threshold=N: Set duplicate tolerance (default 1000) Fixes false positive warnings: Multiple JSONL files found: issues.jsonl, routes.jsonl 70 duplicate issue(s) in 30 group(s) Changes: - Add --gastown flag to bd doctor command - Add --gastown-duplicates-threshold flag (default: 1000) - Update CheckLegacyJSONLFilename to skip routes.jsonl when gastown mode active - Update CheckDuplicateIssues to use configurable threshold when gastown mode active - Add test cases for gastown mode behavior with various thresholds Co-authored-by: Roland Tritsch Co-authored-by: Claude Sonnet 4.5 --- cmd/bd/doctor.go | 10 +- cmd/bd/doctor/legacy.go | 7 +- cmd/bd/doctor/legacy_test.go | 32 ++++- cmd/bd/doctor/validation.go | 34 ++++- cmd/bd/doctor/validation_test.go | 212 ++++++++++++++++++++++++++++++- 5 files changed, 277 insertions(+), 18 deletions(-) diff --git a/cmd/bd/doctor.go b/cmd/bd/doctor.go index a754cdc1..b67b7486 100644 --- a/cmd/bd/doctor.go +++ b/cmd/bd/doctor.go @@ -55,7 +55,9 @@ var ( checkHealthMode bool doctorCheckFlag string // run specific check (e.g., "pollution") doctorClean bool // for pollution check, delete detected issues - doctorDeep bool // full graph integrity validation + doctorDeep bool // full graph integrity validation + doctorGastown bool // running in gastown multi-workspace mode + gastownDuplicatesThreshold int // duplicate tolerance threshold for gastown mode ) // ConfigKeyHintsDoctor is the config key for suppressing doctor hints @@ -226,6 +228,8 @@ func init() { doctorCmd.Flags().BoolVarP(&doctorVerbose, "verbose", "v", false, "Show detailed output during fixes (e.g., list each removed dependency)") doctorCmd.Flags().BoolVar(&doctorForce, "force", false, "Force repair mode: attempt recovery even when database cannot be opened") doctorCmd.Flags().StringVar(&doctorSource, "source", "auto", "Choose source of truth for recovery: auto (detect), jsonl (prefer JSONL), db (prefer database)") + doctorCmd.Flags().BoolVar(&doctorGastown, "gastown", false, "Running in gastown multi-workspace mode (routes.jsonl is expected, higher duplicate tolerance)") + doctorCmd.Flags().IntVar(&gastownDuplicatesThreshold, "gastown-duplicates-threshold", 1000, "Duplicate tolerance threshold for gastown mode (wisps are ephemeral)") } func runDiagnostics(path string) doctorResult { @@ -320,7 +324,7 @@ func runDiagnostics(path string) doctorResult { } // Check 6: Multiple JSONL files (excluding merge artifacts) - jsonlCheck := convertWithCategory(doctor.CheckLegacyJSONLFilename(path), doctor.CategoryData) + jsonlCheck := convertWithCategory(doctor.CheckLegacyJSONLFilename(path, doctorGastown), doctor.CategoryData) result.Checks = append(result.Checks, jsonlCheck) if jsonlCheck.Status == statusWarning || jsonlCheck.Status == statusError { result.OverallOK = false @@ -547,7 +551,7 @@ func runDiagnostics(path string) doctorResult { // Don't fail overall check for child→parent deps, just warn // Check 23: Duplicate issues (from bd validate) - duplicatesCheck := convertDoctorCheck(doctor.CheckDuplicateIssues(path)) + duplicatesCheck := convertDoctorCheck(doctor.CheckDuplicateIssues(path, doctorGastown, gastownDuplicatesThreshold)) result.Checks = append(result.Checks, duplicatesCheck) // Don't fail overall check for duplicates, just warn diff --git a/cmd/bd/doctor/legacy.go b/cmd/bd/doctor/legacy.go index d1494d82..d7e4522d 100644 --- a/cmd/bd/doctor/legacy.go +++ b/cmd/bd/doctor/legacy.go @@ -121,7 +121,8 @@ func CheckAgentDocumentation(repoPath string) DoctorCheck { // CheckLegacyJSONLFilename detects if there are multiple JSONL files, // which can cause sync/merge issues. Ignores merge artifacts and backups. -func CheckLegacyJSONLFilename(repoPath string) DoctorCheck { +// When gastownMode is true, routes.jsonl is treated as a valid system file. +func CheckLegacyJSONLFilename(repoPath string, gastownMode bool) DoctorCheck { beadsDir := filepath.Join(repoPath, ".beads") // Find all .jsonl files @@ -160,7 +161,9 @@ func CheckLegacyJSONLFilename(repoPath string) DoctorCheck { // Git merge conflict artifacts (e.g., issues.base.jsonl, issues.left.jsonl) strings.Contains(lowerName, ".base.jsonl") || strings.Contains(lowerName, ".left.jsonl") || - strings.Contains(lowerName, ".right.jsonl") { + strings.Contains(lowerName, ".right.jsonl") || + // Skip routes.jsonl in gastown mode (valid system file) + (gastownMode && name == "routes.jsonl") { continue } diff --git a/cmd/bd/doctor/legacy_test.go b/cmd/bd/doctor/legacy_test.go index 543f44dc..ab26e96b 100644 --- a/cmd/bd/doctor/legacy_test.go +++ b/cmd/bd/doctor/legacy_test.go @@ -209,96 +209,126 @@ func TestCheckLegacyJSONLFilename(t *testing.T) { tests := []struct { name string files []string + gastownMode bool expectedStatus string expectWarning bool }{ { name: "no JSONL files", files: []string{}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "single issues.jsonl", files: []string{"issues.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "single beads.jsonl is ok", files: []string{"beads.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "custom name is ok", files: []string{"my-issues.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "multiple JSONL files warning", files: []string{"beads.jsonl", "issues.jsonl"}, + gastownMode: false, + expectedStatus: "warning", + expectWarning: true, + }, + { + name: "routes.jsonl with gastown flag", + files: []string{"issues.jsonl", "routes.jsonl"}, + gastownMode: true, + expectedStatus: "ok", + expectWarning: false, + }, + { + name: "routes.jsonl without gastown flag", + files: []string{"issues.jsonl", "routes.jsonl"}, + gastownMode: false, expectedStatus: "warning", expectWarning: true, }, { name: "backup files ignored", files: []string{"issues.jsonl", "issues.jsonl.backup", "BACKUP_issues.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "multiple real files with backups", files: []string{"issues.jsonl", "beads.jsonl", "issues.jsonl.backup"}, + gastownMode: false, expectedStatus: "warning", expectWarning: true, }, { name: "deletions.jsonl ignored as system file", files: []string{"beads.jsonl", "deletions.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "merge artifacts ignored", files: []string{"issues.jsonl", "issues.base.jsonl", "issues.left.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "merge artifacts with right variant ignored", files: []string{"issues.jsonl", "issues.right.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "beads merge artifacts ignored (bd-ov1)", files: []string{"issues.jsonl", "beads.base.jsonl", "beads.left.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "interactions.jsonl ignored as system file (GH#709)", files: []string{"issues.jsonl", "interactions.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "molecules.jsonl ignored as system file", files: []string{"issues.jsonl", "molecules.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "sync_base.jsonl ignored as system file (GH#1021)", files: []string{"issues.jsonl", "sync_base.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, { name: "all system files ignored together", files: []string{"issues.jsonl", "deletions.jsonl", "interactions.jsonl", "molecules.jsonl", "sync_base.jsonl"}, + gastownMode: false, expectedStatus: "ok", expectWarning: false, }, @@ -320,7 +350,7 @@ func TestCheckLegacyJSONLFilename(t *testing.T) { } } - check := CheckLegacyJSONLFilename(tmpDir) + check := CheckLegacyJSONLFilename(tmpDir, tt.gastownMode) if check.Status != tt.expectedStatus { t.Errorf("Expected status %s, got %s", tt.expectedStatus, check.Status) diff --git a/cmd/bd/doctor/validation.go b/cmd/bd/doctor/validation.go index 91d47d3f..35f79dcf 100644 --- a/cmd/bd/doctor/validation.go +++ b/cmd/bd/doctor/validation.go @@ -181,7 +181,9 @@ func CheckOrphanedDependencies(path string) DoctorCheck { } // CheckDuplicateIssues detects issues with identical content. -func CheckDuplicateIssues(path string) DoctorCheck { +// When gastownMode is true, the threshold parameter defines how many duplicates +// are acceptable before warning (default 1000 for gastown's ephemeral wisps). +func CheckDuplicateIssues(path string, gastownMode bool, gastownThreshold int) DoctorCheck { // Follow redirect to resolve actual beads directory (bd-tvus fix) beadsDir := resolveBeadsDir(filepath.Join(path, ".beads")) dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) @@ -236,7 +238,13 @@ func CheckDuplicateIssues(path string) DoctorCheck { } } - if duplicateGroups == 0 { + // Apply threshold based on mode + threshold := 0 // Default: any duplicates are warnings + if gastownMode { + threshold = gastownThreshold // Gastown: configurable threshold (default 1000) + } + + if totalDuplicates == 0 { return DoctorCheck{ Name: "Duplicate Issues", Status: "ok", @@ -244,12 +252,26 @@ func CheckDuplicateIssues(path string) DoctorCheck { } } + // Only warn if duplicate count exceeds threshold + if totalDuplicates > threshold { + return DoctorCheck{ + Name: "Duplicate Issues", + Status: "warning", + Message: fmt.Sprintf("%d duplicate issue(s) in %d group(s)", totalDuplicates, duplicateGroups), + Detail: "Duplicates cannot be auto-fixed", + Fix: "Run 'bd duplicates' to review and merge duplicates", + } + } + + // Under threshold - OK + message := "No duplicate issues" + if gastownMode && totalDuplicates > 0 { + message = fmt.Sprintf("%d duplicate(s) detected (within gastown threshold of %d)", totalDuplicates, threshold) + } return DoctorCheck{ Name: "Duplicate Issues", - Status: "warning", - Message: fmt.Sprintf("%d duplicate issue(s) in %d group(s)", totalDuplicates, duplicateGroups), - Detail: "Duplicates cannot be auto-fixed", - Fix: "Run 'bd duplicates' to review and merge duplicates", + Status: "ok", + Message: message, } } diff --git a/cmd/bd/doctor/validation_test.go b/cmd/bd/doctor/validation_test.go index d04cb4fa..63b7f184 100644 --- a/cmd/bd/doctor/validation_test.go +++ b/cmd/bd/doctor/validation_test.go @@ -53,7 +53,7 @@ func TestCheckDuplicateIssues_ClosedIssuesExcluded(t *testing.T) { // Close the store so CheckDuplicateIssues can open it store.Close() - check := CheckDuplicateIssues(tmpDir) + check := CheckDuplicateIssues(tmpDir, false, 1000) // Should NOT report duplicates because all are closed if check.Status != StatusOK { @@ -99,7 +99,7 @@ func TestCheckDuplicateIssues_OpenDuplicatesDetected(t *testing.T) { store.Close() - check := CheckDuplicateIssues(tmpDir) + check := CheckDuplicateIssues(tmpDir, false, 1000) if check.Status != StatusWarning { t.Errorf("Status = %q, want %q (open duplicates should be detected)", check.Status, StatusWarning) @@ -148,7 +148,7 @@ func TestCheckDuplicateIssues_DifferentDesignNotDuplicate(t *testing.T) { store.Close() - check := CheckDuplicateIssues(tmpDir) + check := CheckDuplicateIssues(tmpDir, false, 1000) if check.Status != StatusOK { t.Errorf("Status = %q, want %q (different design = not duplicates)", check.Status, StatusOK) @@ -200,7 +200,7 @@ func TestCheckDuplicateIssues_MixedOpenClosed(t *testing.T) { store.Close() - check := CheckDuplicateIssues(tmpDir) + check := CheckDuplicateIssues(tmpDir, false, 1000) // Should detect 1 duplicate (the pair of open issues) if check.Status != StatusWarning { @@ -248,7 +248,7 @@ func TestCheckDuplicateIssues_TombstonesExcluded(t *testing.T) { store.Close() - check := CheckDuplicateIssues(tmpDir) + check := CheckDuplicateIssues(tmpDir, false, 1000) if check.Status != StatusOK { t.Errorf("Status = %q, want %q (tombstones should be excluded)", check.Status, StatusOK) @@ -265,7 +265,7 @@ func TestCheckDuplicateIssues_NoDatabase(t *testing.T) { // No database file created - check := CheckDuplicateIssues(tmpDir) + check := CheckDuplicateIssues(tmpDir, false, 1000) if check.Status != StatusOK { t.Errorf("Status = %q, want %q", check.Status, StatusOK) @@ -274,3 +274,203 @@ func TestCheckDuplicateIssues_NoDatabase(t *testing.T) { t.Errorf("Message = %q, want 'N/A (no database)'", check.Message) } } + +// TestCheckDuplicateIssues_GastownUnderThreshold verifies that with gastown mode enabled, +// duplicates under the threshold are OK. +func TestCheckDuplicateIssues_GastownUnderThreshold(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + ctx := context.Background() + + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + // Initialize database with prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue_prefix: %v", err) + } + + // Create 50 duplicate issues (typical gastown wisp count) + for i := 0; i < 51; i++ { + issue := &types.Issue{ + Title: "Check own context limit", + Description: "Wisp for patrol cycle", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + } + + store.Close() + + check := CheckDuplicateIssues(tmpDir, true, 1000) + + // With gastown mode and threshold=1000, 50 duplicates should be OK + if check.Status != StatusOK { + t.Errorf("Status = %q, want %q (under gastown threshold)", check.Status, StatusOK) + t.Logf("Message: %s", check.Message) + } + if check.Message != "50 duplicate(s) detected (within gastown threshold of 1000)" { + t.Errorf("Message = %q, want message about being within threshold", check.Message) + } +} + +// TestCheckDuplicateIssues_GastownOverThreshold verifies that with gastown mode enabled, +// duplicates over the threshold still warn. +func TestCheckDuplicateIssues_GastownOverThreshold(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + ctx := context.Background() + + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + // Initialize database with prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue_prefix: %v", err) + } + + // Create 1500 duplicate issues (over threshold, indicates a problem) + for i := 0; i < 1501; i++ { + issue := &types.Issue{ + Title: "Runaway wisps", + Description: "Too many wisps", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + } + + store.Close() + + check := CheckDuplicateIssues(tmpDir, true, 1000) + + // With gastown mode and threshold=1000, 1500 duplicates should warn + if check.Status != StatusWarning { + t.Errorf("Status = %q, want %q (over gastown threshold)", check.Status, StatusWarning) + } + if check.Message != "1500 duplicate issue(s) in 1 group(s)" { + t.Errorf("Message = %q, want '1500 duplicate issue(s) in 1 group(s)'", check.Message) + } +} + +// TestCheckDuplicateIssues_GastownCustomThreshold verifies custom threshold works. +func TestCheckDuplicateIssues_GastownCustomThreshold(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + ctx := context.Background() + + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + // Initialize database with prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue_prefix: %v", err) + } + + // Create 500 duplicate issues + for i := 0; i < 501; i++ { + issue := &types.Issue{ + Title: "Custom threshold test", + Description: "Test custom threshold", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + } + + store.Close() + + // With custom threshold of 250, 500 duplicates should warn + check := CheckDuplicateIssues(tmpDir, true, 250) + + if check.Status != StatusWarning { + t.Errorf("Status = %q, want %q (over custom threshold of 250)", check.Status, StatusWarning) + } + if check.Message != "500 duplicate issue(s) in 1 group(s)" { + t.Errorf("Message = %q, want '500 duplicate issue(s) in 1 group(s)'", check.Message) + } +} + +// TestCheckDuplicateIssues_NonGastownMode verifies that without gastown mode, +// any duplicates are warnings (backward compatibility). +func TestCheckDuplicateIssues_NonGastownMode(t *testing.T) { + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.Mkdir(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + dbPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) + ctx := context.Background() + + store, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + // Initialize database with prefix + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + t.Fatalf("Failed to set issue_prefix: %v", err) + } + + // Create 50 duplicate issues + for i := 0; i < 51; i++ { + issue := &types.Issue{ + Title: "Duplicate task", + Description: "Some task", + Status: types.StatusOpen, + Priority: 2, + IssueType: types.TypeTask, + } + if err := store.CreateIssue(ctx, issue, "test"); err != nil { + t.Fatalf("Failed to create issue: %v", err) + } + } + + store.Close() + + // Without gastown mode, even 50 duplicates should warn + check := CheckDuplicateIssues(tmpDir, false, 1000) + + if check.Status != StatusWarning { + t.Errorf("Status = %q, want %q (non-gastown should warn on any duplicates)", check.Status, StatusWarning) + } + if check.Message != "50 duplicate issue(s) in 1 group(s)" { + t.Errorf("Message = %q, want '50 duplicate issue(s) in 1 group(s)'", check.Message) + } +}