bd-154: Implement bd daemons stop and restart subcommands
This commit is contained in:
@@ -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"}]}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
15
internal/daemon/kill_unix.go
Normal file
15
internal/daemon/kill_unix.go
Normal 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
|
||||
}
|
||||
18
internal/daemon/kill_windows.go
Normal file
18
internal/daemon/kill_windows.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
OpExport = "export"
|
||||
OpImport = "import"
|
||||
OpEpicStatus = "epic_status"
|
||||
OpShutdown = "shutdown"
|
||||
)
|
||||
|
||||
// Request represents an RPC request from client to daemon
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user