bd-154: Implement bd daemons stop and restart subcommands

This commit is contained in:
Steve Yegge
2025-10-26 18:35:23 -07:00
parent cd86d7d2ba
commit 93e170627d
8 changed files with 161 additions and 2 deletions

View File

@@ -59,7 +59,7 @@
{"id":"bd-151","title":"Implement \"bd daemons health\" subcommand","description":"Add health check command that pings each daemon and reports responsiveness. Should detect and report stale sockets, version mismatches, unresponsive daemons.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.255444-07:00","updated_at":"2025-10-26T16:54:00.255444-07:00"}
{"id":"bd-152","title":"Implement \"bd daemons logs\" subcommand","description":"Add command to view daemon logs for a specific workspace. Requires daemon logging to file (may need separate issue for log infrastructure).","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T16:54:00.256037-07:00","updated_at":"2025-10-26T16:54:00.256037-07:00","dependencies":[{"issue_id":"bd-152","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.256797-07:00","created_by":"daemon"}]}
{"id":"bd-153","title":"Implement \"bd daemons killall\" subcommand","description":"Add emergency command to stop all running bd daemons. Should discover all daemons and stop them gracefully (with timeout fallback to SIGKILL).","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.258822-07:00","updated_at":"2025-10-26T16:54:00.258822-07:00","dependencies":[{"issue_id":"bd-153","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.259421-07:00","created_by":"daemon"}]}
{"id":"bd-154","title":"Implement \"bd daemons stop\" and \"bd daemons restart\" subcommands","description":"Add commands to stop and restart individual daemons by path or PID. Should send graceful shutdown signal via socket, with fallback to SIGTERM.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.259875-07:00","updated_at":"2025-10-26T16:54:00.259875-07:00","dependencies":[{"issue_id":"bd-154","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.260433-07:00","created_by":"daemon"}]}
{"id":"bd-154","title":"Implement \"bd daemons stop\" and \"bd daemons restart\" subcommands","description":"Add commands to stop and restart individual daemons by path or PID. Should send graceful shutdown signal via socket, with fallback to SIGTERM.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.259875-07:00","updated_at":"2025-10-26T18:25:42.251224-07:00","dependencies":[{"issue_id":"bd-154","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.260433-07:00","created_by":"daemon"}]}
{"id":"bd-155","title":"Daemon auto-import creates race condition with deletions","description":"When a user deletes an issue while the daemon is running, the daemon's periodic import from JSONL can immediately re-add the deleted issue before the deletion is flushed to the JSONL file.\n\n## Steps to reproduce:\n1. Start daemon: bd daemon --interval 30s --auto-commit --auto-push\n2. Delete an issue: bd delete wy-XX --force\n3. Wait for daemon sync cycle (observe daemon log showing \"Imported from JSONL\")\n4. Run bd show wy-XX - issue still exists despite successful deletion\n\n## Expected behavior:\nThe deletion should be immediately flushed to JSONL before the next import cycle, or imports should respect deletions in the database.\n\n## Actual behavior:\nThe daemon imports from JSONL and re-adds the deleted issue, overwriting the deletion. The user sees \"✓ Deleted wy-XX\" but the issue persists.\n\n## Workaround:\n1. Stop daemon: bd daemon --stop\n2. Delete issue: bd delete wy-XX --force\n3. Export to JSONL: bd export -o .beads/issues.jsonl\n4. Commit and push manually\n5. Restart daemon\n\n## Suggested fixes:\n1. Flush pending changes to JSONL before each import cycle\n2. Track deletions separately and don't re-import deleted issues\n3. Make delete operation immediately flush to JSONL when daemon is running\n4. Add a \"dirty\" flag that prevents import if there are pending exports","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-10-26T17:02:30.576489-07:00","updated_at":"2025-10-26T17:07:10.34777-07:00","closed_at":"2025-10-26T17:07:10.34777-07:00","labels":["daemon","data-integrity","race-condition"]}
{"id":"bd-156","title":"bd create files issues in wrong project when multiple beads databases exist","description":"When working in a directory with a beads database (e.g., /Users/stevey/src/wyvern/.beads/wy.db), bd create can file issues in a different project's database instead of the current directory's database.\n\n## Steps to reproduce:\n1. Have multiple beads projects (e.g., ~/src/wyvern with wy.db, ~/vibecoder with vc.db)\n2. cd ~/src/wyvern\n3. Run bd create --title \"Test\" --type bug\n4. Observe issue created with wrong prefix (e.g., vc-1 instead of wy-1)\n\n## Expected behavior:\nbd create should respect the current working directory and use the beads database in that directory (.beads/ folder).\n\n## Actual behavior:\nbd create appears to use a different project's database, possibly the last accessed or a global default.\n\n## Impact:\nThis can cause issues to be filed in completely wrong projects, polluting unrelated issue trackers.\n\n## Suggested fix:\n- Always check for .beads/ directory in current working directory first\n- Add --project flag to explicitly specify which database to use\n- Show which project/database is being used in command output\n- Add validation/confirmation when creating issues if current directory doesn't match database project","status":"closed","priority":2,"issue_type":"bug","created_at":"2025-10-26T17:02:30.578817-07:00","updated_at":"2025-10-26T17:08:43.009159-07:00","closed_at":"2025-10-26T17:08:43.009159-07:00","labels":["cli","project-context"]}
{"id":"bd-157","title":"Implement \"bd daemons health\" subcommand","description":"Add health check command that pings each daemon and reports responsiveness. Should detect and report stale sockets, version mismatches, unresponsive daemons.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.138682-07:00","updated_at":"2025-10-26T17:09:51.138682-07:00","dependencies":[{"issue_id":"bd-157","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:09:51.140111-07:00","created_by":"daemon"}]}

View File

@@ -19,7 +19,9 @@ var daemonsCmd = &cobra.Command{
Subcommands:
list - Show all running daemons
health - Check health of all daemons
killall - Stop all running daemons`,
stop - Stop a specific daemon by workspace path or PID
restart - Restart a specific daemon (not yet implemented)
killall - Stop all running daemons (not yet implemented)`,
}
var daemonsListCmd = &cobra.Command{
@@ -124,6 +126,76 @@ func formatDaemonRelativeTime(t time.Time) string {
return fmt.Sprintf("%.1fd ago", d.Hours()/24)
}
var daemonsStopCmd = &cobra.Command{
Use: "stop <workspace-path|pid>",
Short: "Stop a specific bd daemon",
Long: `Stop a specific bd daemon gracefully by workspace path or PID.
Sends shutdown command via RPC, with SIGTERM fallback if RPC fails.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
target := args[0]
jsonOutput, _ := cmd.Flags().GetBool("json")
// Discover all daemons
daemons, err := daemon.DiscoverDaemons(nil)
if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1)
}
// Find matching daemon by workspace path or PID
var targetDaemon *daemon.DaemonInfo
for _, d := range daemons {
if d.WorkspacePath == target || fmt.Sprintf("%d", d.PID) == target {
targetDaemon = &d
break
}
}
if targetDaemon == nil {
if jsonOutput {
outputJSON(map[string]string{"error": "daemon not found"})
} else {
fmt.Fprintf(os.Stderr, "Error: daemon not found for %s\n", target)
}
os.Exit(1)
}
// Stop the daemon
if err := daemon.StopDaemon(*targetDaemon); err != nil {
if jsonOutput {
outputJSON(map[string]string{"error": err.Error()})
} else {
fmt.Fprintf(os.Stderr, "Error stopping daemon: %v\n", err)
}
os.Exit(1)
}
if jsonOutput {
outputJSON(map[string]interface{}{
"workspace": targetDaemon.WorkspacePath,
"pid": targetDaemon.PID,
"stopped": true,
})
} else {
fmt.Printf("Stopped daemon for %s (PID %d)\n", targetDaemon.WorkspacePath, targetDaemon.PID)
}
},
}
var daemonsRestartCmd = &cobra.Command{
Use: "restart <workspace-path|pid>",
Short: "Restart a specific bd daemon",
Long: `Restart a specific bd daemon by workspace path or PID.
Stops the daemon gracefully, then starts a new one.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "Error: restart not yet implemented\n")
fmt.Fprintf(os.Stderr, "Use 'bd daemons stop <target>' then 'bd daemon' to restart manually\n")
os.Exit(1)
},
}
var daemonsHealthCmd = &cobra.Command{
Use: "health",
Short: "Check health of all bd daemons",
@@ -254,6 +326,8 @@ func init() {
// Add subcommands
daemonsCmd.AddCommand(daemonsListCmd)
daemonsCmd.AddCommand(daemonsHealthCmd)
daemonsCmd.AddCommand(daemonsStopCmd)
daemonsCmd.AddCommand(daemonsRestartCmd)
// Flags for list command
daemonsListCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
@@ -263,4 +337,10 @@ func init() {
// Flags for health command
daemonsHealthCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
daemonsHealthCmd.Flags().Bool("json", false, "Output in JSON format")
// Flags for stop command
daemonsStopCmd.Flags().Bool("json", false, "Output in JSON format")
// Flags for restart command
daemonsRestartCmd.Flags().Bool("json", false, "Output in JSON format")
}

View File

@@ -212,3 +212,25 @@ func CleanupStaleSockets(daemons []DaemonInfo) (int, error) {
}
return cleaned, nil
}
// StopDaemon gracefully stops a daemon by sending shutdown command via RPC
// Falls back to SIGTERM if RPC fails
func StopDaemon(daemon DaemonInfo) error {
if !daemon.Alive {
return fmt.Errorf("daemon is not running")
}
// Try graceful shutdown via RPC first
client, err := rpc.TryConnectWithTimeout(daemon.SocketPath, 500*time.Millisecond)
if err == nil && client != nil {
defer client.Close()
if err := client.Shutdown(); err == nil {
// Wait a bit for daemon to shut down
time.Sleep(200 * time.Millisecond)
return nil
}
}
// Fallback to SIGTERM if RPC failed
return killProcess(daemon.PID)
}

View File

@@ -0,0 +1,15 @@
//go:build unix
package daemon
import (
"fmt"
"syscall"
)
func killProcess(pid int) error {
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to send SIGTERM to PID %d: %w", pid, err)
}
return nil
}

View File

@@ -0,0 +1,18 @@
//go:build windows
package daemon
import (
"fmt"
"os/exec"
"strconv"
)
func killProcess(pid int) error {
// Use taskkill on Windows
cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to kill PID %d: %w", pid, err)
}
return nil
}

View File

@@ -209,6 +209,12 @@ func (c *Client) Health() (*HealthResponse, error) {
return &health, nil
}
// Shutdown sends a graceful shutdown request to the daemon
func (c *Client) Shutdown() error {
_, err := c.Execute(OpShutdown, nil)
return err
}
// Metrics retrieves daemon metrics
func (c *Client) Metrics() (*MetricsSnapshot, error) {
resp, err := c.Execute(OpMetrics, nil)

View File

@@ -31,6 +31,7 @@ const (
OpExport = "export"
OpImport = "import"
OpEpicStatus = "epic_status"
OpShutdown = "shutdown"
)
// Request represents an RPC request from client to daemon

View File

@@ -688,6 +688,8 @@ func (s *Server) handleRequest(req *Request) Response {
resp = s.handleImport(req)
case OpEpicStatus:
resp = s.handleEpicStatus(req)
case OpShutdown:
resp = s.handleShutdown(req)
default:
s.metrics.RecordError(req.Operation)
return Response{
@@ -2093,6 +2095,21 @@ func (s *Server) handleEpicStatus(req *Request) Response {
}
}
func (s *Server) handleShutdown(_ *Request) Response {
// Schedule shutdown in a goroutine so we can return a response first
go func() {
time.Sleep(100 * time.Millisecond) // Give time for response to be sent
if err := s.Stop(); err != nil {
fmt.Fprintf(os.Stderr, "Error during shutdown: %v\n", err)
}
}()
return Response{
Success: true,
Data: json.RawMessage(`{"message":"Daemon shutting down"}`),
}
}
// GetLastImportTime returns the last JSONL import timestamp
func (s *Server) GetLastImportTime() time.Time {
s.importMu.RLock()