From c5ee18c6ab3891ea91e66b31c57a3161ff109458 Mon Sep 17 00:00:00 2001 From: dementus Date: Fri, 2 Jan 2026 12:10:36 -0800 Subject: [PATCH] feat(doctor): Add bd daemon health check with actionable error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new doctor check that verifies the bd (beads) daemon is running and healthy. When the daemon fails to start, the check: - Surfaces specific error messages (legacy database, repo mismatch) - Provides one-liner fix commands - Auto-fixes by running bd migrate --update-repo-id when appropriate This addresses GH #25: gt status slow when bd daemon not running. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/doctor.go | 1 + internal/doctor/bd_daemon_check.go | 212 +++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 internal/doctor/bd_daemon_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index ccd623f2..86086fb7 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -101,6 +101,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewDaemonCheck()) d.Register(doctor.NewBootHealthCheck()) d.Register(doctor.NewBeadsDatabaseCheck()) + d.Register(doctor.NewBdDaemonCheck()) d.Register(doctor.NewPrefixConflictCheck()) d.Register(doctor.NewRoutesCheck()) d.Register(doctor.NewOrphanSessionCheck()) diff --git a/internal/doctor/bd_daemon_check.go b/internal/doctor/bd_daemon_check.go new file mode 100644 index 00000000..0c52890c --- /dev/null +++ b/internal/doctor/bd_daemon_check.go @@ -0,0 +1,212 @@ +package doctor + +import ( + "bytes" + "os/exec" + "strings" +) + +// BdDaemonCheck verifies that the bd (beads) daemon is running and healthy. +// When the daemon fails to start, it surfaces the actual error (e.g., legacy +// database detected, repo mismatch) and provides actionable fix commands. +type BdDaemonCheck struct { + FixableCheck +} + +// NewBdDaemonCheck creates a new bd daemon check. +func NewBdDaemonCheck() *BdDaemonCheck { + return &BdDaemonCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "bd-daemon", + CheckDescription: "Check if bd (beads) daemon is running", + }, + }, + } +} + +// Run checks if the bd daemon is running and healthy. +func (c *BdDaemonCheck) Run(ctx *CheckContext) *CheckResult { + // Check daemon status + cmd := exec.Command("bd", "daemon", "--status") + cmd.Dir = ctx.TownRoot + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + output := strings.TrimSpace(stdout.String() + stderr.String()) + + // Check if daemon is running + if err == nil && strings.Contains(output, "Daemon is running") { + // Daemon is running, now check health + healthCmd := exec.Command("bd", "daemon", "--health") + healthCmd.Dir = ctx.TownRoot + var healthOut bytes.Buffer + healthCmd.Stdout = &healthOut + healthCmd.Run() // Ignore error, health check is optional + + healthOutput := healthOut.String() + if strings.Contains(healthOutput, "HEALTHY") { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "bd daemon is running and healthy", + } + } + + // Daemon running but unhealthy + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "bd daemon is running but may be unhealthy", + Details: []string{strings.TrimSpace(healthOutput)}, + } + } + + // Daemon is not running - try to start it and capture any errors + startErr := c.tryStartDaemon(ctx) + if startErr != nil { + // Parse the error to provide specific guidance + return c.parseStartError(startErr) + } + + // Started successfully + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "bd daemon started successfully", + } +} + +// tryStartDaemon attempts to start the bd daemon and returns any error output. +func (c *BdDaemonCheck) tryStartDaemon(ctx *CheckContext) *startError { + cmd := exec.Command("bd", "daemon", "--start") + cmd.Dir = ctx.TownRoot + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return &startError{ + output: strings.TrimSpace(stdout.String() + stderr.String()), + exitCode: cmd.ProcessState.ExitCode(), + } + } + return nil +} + +// startError holds information about a failed daemon start. +type startError struct { + output string + exitCode int +} + +// parseStartError analyzes the error output and returns a helpful CheckResult. +func (c *BdDaemonCheck) parseStartError(err *startError) *CheckResult { + output := err.output + + // Check for legacy database error + if strings.Contains(output, "LEGACY DATABASE DETECTED") { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "bd daemon failed: legacy database detected", + Details: []string{ + "Database was created before bd version 0.17.5", + "Missing repository fingerprint prevents daemon from starting", + }, + FixHint: "Run 'bd migrate --update-repo-id' to add fingerprint", + } + } + + // Check for database mismatch error + if strings.Contains(output, "DATABASE MISMATCH DETECTED") { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "bd daemon failed: database belongs to different repository", + Details: []string{ + "The .beads database was created for a different git repository", + "This can happen if .beads was copied or if the git remote URL changed", + }, + FixHint: "Run 'bd migrate --update-repo-id' if URL changed, or 'rm -rf .beads && bd init' for fresh start", + } + } + + // Check for already running (not actually an error) + if strings.Contains(output, "daemon already running") { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "bd daemon is already running", + } + } + + // Check for permission/lock errors + if strings.Contains(output, "lock") || strings.Contains(output, "permission") { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "bd daemon failed: lock or permission issue", + Details: []string{output}, + FixHint: "Check if another bd daemon is running, or remove .beads/daemon.lock", + } + } + + // Check for database corruption + if strings.Contains(output, "corrupt") || strings.Contains(output, "malformed") { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "bd daemon failed: database may be corrupted", + Details: []string{output}, + FixHint: "Run 'bd repair' or 'rm .beads/issues.db && bd sync --from-main'", + } + } + + // Generic error with full output + details := []string{output} + if output == "" { + details = []string{"No error output captured (exit code " + string(rune('0'+err.exitCode)) + ")"} + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "bd daemon failed to start", + Details: details, + FixHint: "Check 'bd daemon --status' and logs in .beads/daemon.log", + } +} + +// Fix attempts to start the bd daemon. +func (c *BdDaemonCheck) Fix(ctx *CheckContext) error { + // First check if it's a legacy database issue + startErr := c.tryStartDaemon(ctx) + if startErr == nil { + return nil + } + + // If legacy database, run migrate first + if strings.Contains(startErr.output, "LEGACY DATABASE") || + strings.Contains(startErr.output, "DATABASE MISMATCH") { + + migrateCmd := exec.Command("bd", "migrate", "--update-repo-id", "--yes") + migrateCmd.Dir = ctx.TownRoot + if err := migrateCmd.Run(); err != nil { + return err + } + + // Try starting again + startCmd := exec.Command("bd", "daemon", "--start") + startCmd.Dir = ctx.TownRoot + return startCmd.Run() + } + + // For other errors, just try to start + startCmd := exec.Command("bd", "daemon", "--start") + startCmd.Dir = ctx.TownRoot + return startCmd.Run() +}