diff --git a/internal/beads/daemon.go b/internal/beads/daemon.go new file mode 100644 index 00000000..7f6c48dc --- /dev/null +++ b/internal/beads/daemon.go @@ -0,0 +1,134 @@ +package beads + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "time" +) + +// BdDaemonInfo represents the status of a single bd daemon instance. +type BdDaemonInfo struct { + Workspace string `json:"workspace"` + SocketPath string `json:"socket_path"` + PID int `json:"pid"` + Version string `json:"version"` + Status string `json:"status"` + Issue string `json:"issue,omitempty"` + VersionMismatch bool `json:"version_mismatch,omitempty"` +} + +// BdDaemonHealth represents the overall health of bd daemons. +type BdDaemonHealth struct { + Total int `json:"total"` + Healthy int `json:"healthy"` + Stale int `json:"stale"` + Mismatched int `json:"mismatched"` + Unresponsive int `json:"unresponsive"` + Daemons []BdDaemonInfo `json:"daemons"` +} + +// CheckBdDaemonHealth checks the health of all bd daemons. +// Returns nil if no daemons are running (which is fine, bd will use direct mode). +func CheckBdDaemonHealth() (*BdDaemonHealth, error) { + cmd := exec.Command("bd", "daemon", "health", "--json") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + // bd daemon health may fail if bd not installed or other issues + // Return nil to indicate we can't check (not an error for status display) + return nil, nil + } + + var health BdDaemonHealth + if err := json.Unmarshal(stdout.Bytes(), &health); err != nil { + return nil, fmt.Errorf("parsing daemon health: %w", err) + } + + return &health, nil +} + +// EnsureBdDaemonHealth checks if bd daemons are healthy and attempts to restart if needed. +// Returns a warning message if there were issues, or empty string if everything is fine. +// This is non-blocking - it will not fail if daemons can't be started. +func EnsureBdDaemonHealth(workDir string) string { + health, err := CheckBdDaemonHealth() + if err != nil || health == nil { + // Can't check daemon health - proceed without warning + return "" + } + + // No daemons running is fine - bd will use direct mode + if health.Total == 0 { + return "" + } + + // Check if any daemons need attention + needsRestart := false + var issues []string + + for _, d := range health.Daemons { + switch d.Status { + case "healthy": + // Good + case "version_mismatch": + needsRestart = true + issues = append(issues, fmt.Sprintf("%s: version mismatch", d.Workspace)) + case "stale": + needsRestart = true + issues = append(issues, fmt.Sprintf("%s: stale", d.Workspace)) + case "unresponsive": + needsRestart = true + issues = append(issues, fmt.Sprintf("%s: unresponsive", d.Workspace)) + } + } + + if !needsRestart { + return "" + } + + // Attempt to restart daemons + if restartErr := restartBdDaemons(); restartErr != nil { + return fmt.Sprintf("bd daemons unhealthy (restart failed: %v)", restartErr) + } + + // Verify restart worked + time.Sleep(500 * time.Millisecond) + newHealth, err := CheckBdDaemonHealth() + if err != nil || newHealth == nil { + return "bd daemons restarted but status unknown" + } + + if newHealth.Healthy < newHealth.Total { + return fmt.Sprintf("bd daemons partially healthy (%d/%d)", newHealth.Healthy, newHealth.Total) + } + + return "" // Successfully restarted +} + +// restartBdDaemons restarts all bd daemons. +func restartBdDaemons() error { + // Stop all daemons first + stopCmd := exec.Command("bd", "daemon", "killall") + stopCmd.Run() // Ignore errors - daemons might not be running + + // Give time for cleanup + time.Sleep(200 * time.Millisecond) + + // Start daemons for known locations + // The daemon will auto-start when bd commands are run in those directories + // Just running any bd command will trigger daemon startup if configured + return nil +} + +// StartBdDaemonIfNeeded starts the bd daemon for a specific workspace if not running. +// This is a best-effort operation - failures are logged but don't block execution. +func StartBdDaemonIfNeeded(workDir string) error { + cmd := exec.Command("bd", "daemon", "--start") + cmd.Dir = workDir + return cmd.Run() +} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index a096d33f..0e94e4f8 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -126,6 +126,10 @@ func runStatus(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a Gas Town workspace: %w", err) } + // Check bd daemon health and attempt restart if needed + // This is non-blocking - if daemons can't be started, we show a warning but continue + bdWarning := beads.EnsureBdDaemonHealth(townRoot) + // Load town config townConfigPath := constants.MayorTownPath(townRoot) townConfig, err := config.LoadTownConfig(townConfigPath) @@ -302,7 +306,17 @@ func runStatus(cmd *cobra.Command, args []string) error { if statusJSON { return outputStatusJSON(status) } - return outputStatusText(status) + if err := outputStatusText(status); err != nil { + return err + } + + // Show bd daemon warning at the end if there were issues + if bdWarning != "" { + fmt.Printf("%s %s\n", style.Warning.Render("⚠"), bdWarning) + fmt.Printf(" Run 'bd daemon killall && bd daemon --start' to restart daemons\n") + } + + return nil } func outputStatusJSON(status TownStatus) error {