From 93e170627d0899d7e35d3b02f50a10cfd8fbf7a0 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Sun, 26 Oct 2025 18:35:23 -0700 Subject: [PATCH] bd-154: Implement bd daemons stop and restart subcommands --- .beads/beads.jsonl | 2 +- cmd/bd/daemons.go | 82 ++++++++++++++++++++++++++++++++- internal/daemon/discovery.go | 22 +++++++++ internal/daemon/kill_unix.go | 15 ++++++ internal/daemon/kill_windows.go | 18 ++++++++ internal/rpc/client.go | 6 +++ internal/rpc/protocol.go | 1 + internal/rpc/server.go | 17 +++++++ 8 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 internal/daemon/kill_unix.go create mode 100644 internal/daemon/kill_windows.go diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 25d64db2..bfae83e7 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -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"}]} diff --git a/cmd/bd/daemons.go b/cmd/bd/daemons.go index 24957a77..35a0e797 100644 --- a/cmd/bd/daemons.go +++ b/cmd/bd/daemons.go @@ -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 ", + 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 ", + 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 ' 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") } diff --git a/internal/daemon/discovery.go b/internal/daemon/discovery.go index 7d6f5a7e..11f706c8 100644 --- a/internal/daemon/discovery.go +++ b/internal/daemon/discovery.go @@ -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) +} diff --git a/internal/daemon/kill_unix.go b/internal/daemon/kill_unix.go new file mode 100644 index 00000000..4b78f2fd --- /dev/null +++ b/internal/daemon/kill_unix.go @@ -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 +} diff --git a/internal/daemon/kill_windows.go b/internal/daemon/kill_windows.go new file mode 100644 index 00000000..5b258138 --- /dev/null +++ b/internal/daemon/kill_windows.go @@ -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 +} diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 3bd98e17..7a7c9071 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -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) diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index 5f7f99ba..c5f25b74 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -31,6 +31,7 @@ const ( OpExport = "export" OpImport = "import" OpEpicStatus = "epic_status" + OpShutdown = "shutdown" ) // Request represents an RPC request from client to daemon diff --git a/internal/rpc/server.go b/internal/rpc/server.go index fb43d95b..1567587b 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -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()