feat(daemons): implement discovery and list command (bd-146, bd-147)

- Add daemon discovery mechanism with socket scanning
- Implement depth-limited filesystem walk to avoid hangs
- Add DaemonInfo struct with metadata collection
- Create 'bd daemons list' command with table and JSON output
- Add FindDaemonByWorkspace and CleanupStaleSockets utilities
- Fix workspace path to be parent of .beads directory
- Add comprehensive tests for discovery functionality

Closes bd-146
Closes bd-147
This commit is contained in:
Steve Yegge
2025-10-26 18:10:24 -07:00
parent 75c959e69c
commit c61ca494fe
5 changed files with 453 additions and 4 deletions

View File

@@ -50,8 +50,8 @@
{"id":"bd-143","title":"Review and respond to new GitHub PRs","description":"Check for new pull requests on GitHub and review/respond to them.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T13:19:22.407693-07:00","updated_at":"2025-10-26T13:22:33.395599-07:00","closed_at":"2025-10-26T13:22:33.395599-07:00"}
{"id":"bd-144","title":"Document bd edit command and verify MCP exclusion","description":"Follow-up from PR #152:\n1. Add \"bd edit\" to AGENTS.md with \"Humans only\" note\n2. Verify MCP server doesn't expose bd edit command\n3. Consider adding test for command registration","status":"open","priority":2,"issue_type":"task","created_at":"2025-10-26T13:23:47.982295-07:00","updated_at":"2025-10-26T13:23:47.982295-07:00","dependencies":[{"issue_id":"bd-144","depends_on_id":"bd-143","type":"discovered-from","created_at":"2025-10-26T13:23:47.983557-07:00","created_by":"daemon"}]}
{"id":"bd-145","title":"Add \"bd daemons\" command for multi-daemon management","description":"Add a new \"bd daemons\" command with subcommands to manage daemon processes across all beads repositories/worktrees. Should show all running daemons with metadata (version, workspace, uptime, last sync), allow stopping/restarting individual daemons, auto-clean stale processes, view logs, and show exclusive lock status.","design":"Subcommands:\n- list: Show all running daemons with metadata (workspace, PID, version, socket path, uptime, last activity, exclusive lock status)\n- stop \u003cpath|pid\u003e: Gracefully stop a specific daemon\n- restart \u003cpath|pid\u003e: Stop and restart daemon\n- killall: Emergency stop all daemons\n- health: Verify each daemon responds to ping\n- logs \u003cpath\u003e: View daemon logs\n\nFeatures:\n- Auto-clean stale sockets/dead processes\n- Discovery: Scan for .beads/bd.sock files + running processes\n- Communication: Use existing socket protocol, add GET /status endpoint for metadata","status":"open","priority":1,"issue_type":"epic","created_at":"2025-10-26T16:53:40.970042-07:00","updated_at":"2025-10-26T16:53:40.970042-07:00"}
{"id":"bd-146","title":"Implement daemon discovery mechanism","description":"Build the core discovery logic to find all running bd daemons. Scan filesystem for .beads/bd.sock files, check if processes are alive, and collect metadata.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.22163-07:00","updated_at":"2025-10-26T17:55:32.40408-07:00","dependencies":[{"issue_id":"bd-146","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.222398-07:00","created_by":"daemon"}]}
{"id":"bd-147","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.232956-07:00","updated_at":"2025-10-26T16:54:00.232956-07:00","dependencies":[{"issue_id":"bd-147","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:47:47.922208-07:00","created_by":"stevey"}]}
{"id":"bd-146","title":"Implement daemon discovery mechanism","description":"Build the core discovery logic to find all running bd daemons. Scan filesystem for .beads/bd.sock files, check if processes are alive, and collect metadata.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.22163-07:00","updated_at":"2025-10-26T18:05:22.257361-07:00","closed_at":"2025-10-26T18:05:22.257361-07:00","dependencies":[{"issue_id":"bd-146","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.222398-07:00","created_by":"daemon"}]}
{"id":"bd-147","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.232956-07:00","updated_at":"2025-10-26T18:06:29.211531-07:00","dependencies":[{"issue_id":"bd-147","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:47:47.922208-07:00","created_by":"stevey"}]}
{"id":"bd-148","title":"Add GET /status endpoint to daemon HTTP server","description":"Add a new HTTP endpoint that returns daemon metadata: version, workspace path, PID, uptime, last activity timestamp, exclusive lock status.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.233084-07:00","updated_at":"2025-10-26T17:55:32.40399-07:00","closed_at":"2025-10-26T17:55:32.40399-07:00","dependencies":[{"issue_id":"bd-148","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.238293-07:00","created_by":"daemon"}]}
{"id":"bd-149","title":"Add auto-cleanup of stale sockets and dead processes","description":"When discovering daemons, automatically detect and clean up stale socket files (where process is dead) and orphaned PID files. Should be safe and only remove confirmed-dead processes.","status":"open","priority":1,"issue_type":"task","created_at":"2025-10-26T16:54:00.246629-07:00","updated_at":"2025-10-26T16:54:00.246629-07:00","dependencies":[{"issue_id":"bd-149","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T16:54:00.247788-07:00","created_by":"daemon"}]}
{"id":"bd-15","title":"Phase 4: Gradual Cutover \u0026 Production Rollout","description":"Replace SQLite implementation with Beads library in production and remove legacy code.\n\n**Goal:** Complete transition to Beads library, deprecate and remove custom SQLite implementation.\n\n**Key Tasks:**\n1. Run VC executor with Beads library in CI\n2. Dogfood: Use Beads library for VC's own development\n3. Monitor for regressions and performance issues\n4. Flip feature flag: VC_USE_BEADS_LIBRARY=true by default\n5. Monitor production logs for errors\n6. Collect user feedback\n7. Add deprecation notice to CLAUDE.md\n8. Provide migration guide for users\n9. Remove legacy code: internal/storage/sqlite/sqlite.go (~1500 lines)\n10. Remove migration framework: internal/storage/migrations/\n11. Remove manual transaction management code\n12. Update all documentation\n\n**Acceptance Criteria:**\n- Beads library enabled by default in production\n- Zero production incidents related to migration\n- Performance meets or exceeds SQLite implementation\n- All tests passing with Beads library\n- Legacy SQLite code removed\n- Documentation updated\n- Celebration documented 🎉\n\n**Rollout Strategy:**\n1. Week 1: Enable for CI/testing environments\n2. Week 2: Dogfood on VC development\n3. Week 3: Enable for 50% of production (canary)\n4. Week 4: Enable for 100% of production\n5. Week 5: Remove legacy code\n\n**Monitoring:**\n- Track error rates before/after cutover\n- Monitor database query performance\n- Track issue creation/update latency\n- Monitor executor claim performance\n\n**Rollback Plan:**\n- Keep VC_FORCE_SQLITE=true escape hatch for 2 weeks post-cutover\n- Keep legacy code for 1 sprint after cutover\n- Document rollback procedure\n\n**Success Metrics:**\n- Zero data loss\n- No performance regression (\u003c 5% latency increase acceptable)\n- Reduced maintenance burden (code LOC reduction)\n- Positive developer feedback\n\n**Dependencies:**\n- Blocked by Phase 3 (need migration tooling)\n\n**Estimated Effort:** 1 sprint","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-22T14:05:07.755107-07:00","updated_at":"2025-10-25T23:15:33.474948-07:00","closed_at":"2025-10-22T21:37:48.748919-07:00","dependencies":[{"issue_id":"bd-15","depends_on_id":"bd-11","type":"parent-child","created_at":"2025-10-24T13:17:40.324637-07:00","created_by":"renumber"},{"issue_id":"bd-15","depends_on_id":"bd-14","type":"blocks","created_at":"2025-10-24T13:17:40.324851-07:00","created_by":"renumber"}]}
@@ -64,6 +64,7 @@
{"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":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.138682-07:00","updated_at":"2025-10-26T17:47:47.958834-07:00","closed_at":"2025-10-26T17:47:47.958834-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"}]}
{"id":"bd-158","title":"Implement \"bd daemons list\" subcommand","description":"Create the \"bd daemons list\" command that displays all running daemons in a table with: workspace path, PID, version, socket path, uptime, last activity, exclusive lock status. Include --json flag.","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-26T17:09:51.140442-07:00","updated_at":"2025-10-26T17:47:47.929666-07:00","closed_at":"2025-10-26T17:47:47.929666-07:00","dependencies":[{"issue_id":"bd-158","depends_on_id":"bd-145","type":"parent-child","created_at":"2025-10-26T17:09:51.150077-07:00","created_by":"daemon"}]}
{"id":"bd-159","title":"Timestamp-only changes still being exported despite dedup logic","description":"User observed timestamp-only changes in .beads/beads.jsonl causing dirty working tree. Example: bd-128's updated_at changed from 2025-10-25T23:51:09.811006-07:00 to 2025-10-26T14:12:45.207573-07:00 with no other field changes.\n\nThis should have been prevented by the export deduplication logic that's supposed to skip timestamp-only updates.\n\nNeed to investigate why timestamp-only changes are still being exported and fix the dedup logic.","status":"open","priority":1,"issue_type":"bug","created_at":"2025-10-26T17:58:15.41007-07:00","updated_at":"2025-10-26T17:58:15.41007-07:00"}
{"id":"bd-16","title":"Add lifecycle safety docs and tests for UnderlyingDB() method","description":"The new UnderlyingDB() method exposes the raw *sql.DB connection for extensions like VC to create their own tables. While database/sql is concurrency-safe, there are lifecycle and misuse risks that need documentation and testing.\n\n**What needs to be done:**\n\n1. **Enhanced documentation** - Expand UnderlyingDB() comments to warn:\n - Callers MUST NOT call Close() on returned DB\n - Do NOT change pool/driver settings (SetMaxOpenConns, SetConnMaxIdleTime)\n - Do NOT modify SQLite PRAGMAs (WAL mode, journal, etc.)\n - Expect errors after Storage.Close() - use contexts\n - Keep write transactions short to avoid blocking core storage\n\n2. **Add lifecycle tracking** - Implement closed flag:\n - Add atomic.Bool closed field to SQLiteStorage\n - Set flag in Close(), clear in New()\n - Optional: Add IsClosed() bool method\n\n3. **Add safety tests** (run with -race):\n - TestUnderlyingDB_ConcurrentAccess - N goroutines using UnderlyingDB() during normal storage ops\n - TestUnderlyingDB_AfterClose - Verify operations fail cleanly after storage closed\n - TestUnderlyingDB_CreateExtensionTables - Create VC table with FK to issues, verify FK enforcement\n - TestUnderlyingDB_LongTxDoesNotCorrupt - Ensure long read tx doesn't block writes indefinitely\n\n**Why this matters:**\nVC will use this to create tables in the same database. Need to ensure production-ready safety without over-engineering.\n\n**Estimated effort:** S+S+S = M total (1-3h)","design":"Oracle recommends \"simple path\": enhanced docs + minimal guardrails + focused tests. See oracle output for detailed rationale on concurrency safety, lifecycle risks, and when to consider advanced path (wrapping interface).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T17:07:56.812983-07:00","updated_at":"2025-10-25T23:15:33.476053-07:00","closed_at":"2025-10-22T20:10:52.636372-07:00"}
{"id":"bd-17","title":"Update EXTENDING.md with UnderlyingDB() usage and best practices","description":"EXTENDING.md currently shows how to use direct sql.Open() to access the database, but doesn't mention the new UnderlyingDB() method that's the recommended way for extensions.\n\n**Update needed:**\n1. Add section showing UnderlyingDB() usage:\n ```go\n store, err := beads.NewSQLiteStorage(dbPath)\n db := store.UnderlyingDB()\n // Create extension tables using db\n ```\n\n2. Document when to use UnderlyingDB() vs direct sql.Open():\n - Use UnderlyingDB() when you want to share the storage connection\n - Use sql.Open() when you need independent connection management\n\n3. Add safety warnings (cross-reference from UnderlyingDB() docs):\n - Don't close the DB\n - Don't modify pool settings\n - Keep transactions short\n\n4. Update the VC example to show UnderlyingDB() pattern\n\n5. Explain beads.Storage.UnderlyingDB() in the API section","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-22T17:07:56.820056-07:00","updated_at":"2025-10-25T23:15:33.478579-07:00","closed_at":"2025-10-22T19:41:19.895847-07:00","dependencies":[{"issue_id":"bd-17","depends_on_id":"bd-10","type":"discovered-from","created_at":"2025-10-24T13:17:40.32522-07:00","created_by":"renumber"}]}
{"id":"bd-18","title":"Consider adding UnderlyingConn(ctx) for safer scoped DB access","description":"Currently UnderlyingDB() returns *sql.DB which is correct for most uses, but for extension migrations/DDL, a scoped connection might be safer.\n\n**Proposal:** Add optional UnderlyingConn(ctx) (*sql.Conn, error) method that:\n- Returns a scoped connection via s.db.Conn(ctx)\n- Encourages lifetime-bounded usage\n- Reduces temptation to tune global pool settings\n- Better for one-time DDL operations like CREATE TABLE\n\n**Implementation:**\n```go\n// UnderlyingConn returns a single connection from the pool for scoped use\n// Useful for migrations and DDL. Close the connection when done.\nfunc (s *SQLiteStorage) UnderlyingConn(ctx context.Context) (*sql.Conn, error) {\n return s.db.Conn(ctx)\n}\n```\n\n**Benefits:**\n- Safer for migrations (explicit scope)\n- Complements UnderlyingDB() for different use cases\n- Low implementation cost\n\n**Trade-off:** Adds another method to maintain, but Oracle considers this balanced compromise between safety and flexibility.\n\n**Decision:** This is optional - evaluate based on VC's actual usage patterns.","status":"closed","priority":3,"issue_type":"feature","created_at":"2025-10-22T17:07:56.832638-07:00","updated_at":"2025-10-25T23:15:33.479496-07:00","closed_at":"2025-10-22T22:02:18.479512-07:00","dependencies":[{"issue_id":"bd-18","depends_on_id":"bd-10","type":"related","created_at":"2025-10-24T13:17:40.325463-07:00","created_by":"renumber"}]}

View File

@@ -1079,8 +1079,11 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush bool, logPath, p
defer func() { _ = store.Close() }()
log.log("Database opened: %s", daemonDBPath)
workspacePath := filepath.Dir(daemonDBPath)
socketPath := filepath.Join(workspacePath, "bd.sock")
// Get workspace path (.beads directory)
beadsDir := filepath.Dir(daemonDBPath)
// Get actual workspace root (parent of .beads)
workspacePath := filepath.Dir(beadsDir)
socketPath := filepath.Join(beadsDir, "bd.sock")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

125
cmd/bd/daemons.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"fmt"
"os"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/daemon"
)
var daemonsCmd = &cobra.Command{
Use: "daemons",
Short: "Manage multiple bd daemons",
Long: `Manage bd daemon processes across all repositories and worktrees.
Subcommands:
list - Show all running daemons
health - Check health of all daemons
killall - Stop all running daemons`,
}
var daemonsListCmd = &cobra.Command{
Use: "list",
Short: "List all running bd daemons",
Long: `List all running bd daemons with metadata including workspace path, PID, version,
uptime, last activity, and exclusive lock status.`,
Run: func(cmd *cobra.Command, args []string) {
searchRoots, _ := cmd.Flags().GetStringSlice("search")
jsonOutput, _ := cmd.Flags().GetBool("json")
// Discover daemons
daemons, err := daemon.DiscoverDaemons(searchRoots)
if err != nil {
fmt.Fprintf(os.Stderr, "Error discovering daemons: %v\n", err)
os.Exit(1)
}
// Filter to only alive daemons
var aliveDaemons []daemon.DaemonInfo
for _, d := range daemons {
if d.Alive {
aliveDaemons = append(aliveDaemons, d)
}
}
if jsonOutput {
data, _ := json.MarshalIndent(aliveDaemons, "", " ")
fmt.Println(string(data))
return
}
// Human-readable table output
if len(aliveDaemons) == 0 {
fmt.Println("No running daemons found")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "WORKSPACE\tPID\tVERSION\tUPTIME\tLAST ACTIVITY\tLOCK")
for _, d := range aliveDaemons {
workspace := d.WorkspacePath
if workspace == "" {
workspace = "(unknown)"
}
uptime := formatDaemonDuration(d.UptimeSeconds)
lastActivity := "(unknown)"
if d.LastActivityTime != "" {
if t, err := time.Parse(time.RFC3339, d.LastActivityTime); err == nil {
lastActivity = formatDaemonRelativeTime(t)
}
}
lock := "-"
if d.ExclusiveLockActive {
lock = fmt.Sprintf("🔒 %s", d.ExclusiveLockHolder)
}
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\t%s\n",
workspace, d.PID, d.Version, uptime, lastActivity, lock)
}
w.Flush()
},
}
func formatDaemonDuration(seconds float64) string {
d := time.Duration(seconds * float64(time.Second))
if d < time.Minute {
return fmt.Sprintf("%.0fs", d.Seconds())
} else if d < time.Hour {
return fmt.Sprintf("%.0fm", d.Minutes())
} else if d < 24*time.Hour {
return fmt.Sprintf("%.1fh", d.Hours())
}
return fmt.Sprintf("%.1fd", d.Hours()/24)
}
func formatDaemonRelativeTime(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
} else if d < time.Hour {
return fmt.Sprintf("%.0fm ago", d.Minutes())
} else if d < 24*time.Hour {
return fmt.Sprintf("%.1fh ago", d.Hours())
}
return fmt.Sprintf("%.1fd ago", d.Hours()/24)
}
func init() {
rootCmd.AddCommand(daemonsCmd)
// Add subcommands
daemonsCmd.AddCommand(daemonsListCmd)
// Flags for list command
daemonsListCmd.Flags().StringSlice("search", nil, "Directories to search for daemons (default: home, /tmp, cwd)")
daemonsListCmd.Flags().Bool("json", false, "Output in JSON format")
}

View File

@@ -0,0 +1,203 @@
package daemon
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/beads/internal/rpc"
)
// walkWithDepth walks a directory tree with depth limiting
func walkWithDepth(root string, currentDepth, maxDepth int, fn func(path string, info os.FileInfo) error) error {
if currentDepth > maxDepth {
return nil
}
entries, err := os.ReadDir(root)
if err != nil {
// Skip directories we can't read
return nil
}
for _, entry := range entries {
path := filepath.Join(root, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
// Skip common directories that won't have beads databases
if info.IsDir() {
name := entry.Name()
if strings.HasPrefix(name, ".") && name != ".beads" {
continue // Skip hidden dirs except .beads
}
if name == "node_modules" || name == "vendor" || name == ".git" {
continue
}
// Recurse into subdirectory
if err := walkWithDepth(path, currentDepth+1, maxDepth, fn); err != nil {
return err
}
} else {
// Process file
if err := fn(path, info); err != nil {
return err
}
}
}
return nil
}
// DaemonInfo represents metadata about a discovered daemon
type DaemonInfo struct {
WorkspacePath string
DatabasePath string
SocketPath string
PID int
Version string
UptimeSeconds float64
LastActivityTime string
ExclusiveLockActive bool
ExclusiveLockHolder string
Alive bool
Error string
}
// DiscoverDaemons scans the filesystem for running bd daemons
// It searches common locations and uses the Status RPC endpoint to gather metadata
func DiscoverDaemons(searchRoots []string) ([]DaemonInfo, error) {
var daemons []DaemonInfo
seen := make(map[string]bool)
// If no search roots provided, use common locations
if len(searchRoots) == 0 {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
searchRoots = []string{
home,
"/tmp",
}
// Also add current directory if in a git repo
if cwd, err := os.Getwd(); err == nil {
searchRoots = append(searchRoots, cwd)
}
}
// Search for .beads/bd.sock files (limit depth to avoid traversing entire filesystem)
for _, root := range searchRoots {
maxDepth := 10 // Limit recursion depth
if err := walkWithDepth(root, 0, maxDepth, func(path string, info os.FileInfo) error {
// Skip if not a socket file
if info.Name() != "bd.sock" {
return nil
}
// Skip if already seen this socket
if seen[path] {
return nil
}
seen[path] = true
// Try to connect and get status
daemon := discoverDaemon(path)
daemons = append(daemons, daemon)
return nil
}); err != nil {
// Continue searching other roots even if one fails
continue
}
}
return daemons, nil
}
// discoverDaemon attempts to connect to a daemon socket and retrieve its status
func discoverDaemon(socketPath string) DaemonInfo {
daemon := DaemonInfo{
SocketPath: socketPath,
Alive: false,
}
// Try to connect with short timeout
client, err := rpc.TryConnectWithTimeout(socketPath, 500*time.Millisecond)
if err != nil {
daemon.Error = fmt.Sprintf("failed to connect: %v", err)
return daemon
}
if client == nil {
daemon.Error = "daemon not responding or unhealthy"
return daemon
}
defer client.Close()
// Get status
status, err := client.Status()
if err != nil {
daemon.Error = fmt.Sprintf("failed to get status: %v", err)
return daemon
}
// Populate daemon info from status
daemon.Alive = true
daemon.WorkspacePath = status.WorkspacePath
daemon.DatabasePath = status.DatabasePath
daemon.PID = status.PID
daemon.Version = status.Version
daemon.UptimeSeconds = status.UptimeSeconds
daemon.LastActivityTime = status.LastActivityTime
daemon.ExclusiveLockActive = status.ExclusiveLockActive
daemon.ExclusiveLockHolder = status.ExclusiveLockHolder
return daemon
}
// FindDaemonByWorkspace finds a daemon serving a specific workspace
func FindDaemonByWorkspace(workspacePath string) (*DaemonInfo, error) {
// First try the socket in the workspace itself
socketPath := filepath.Join(workspacePath, ".beads", "bd.sock")
if _, err := os.Stat(socketPath); err == nil {
daemon := discoverDaemon(socketPath)
if daemon.Alive {
return &daemon, nil
}
}
// Fall back to discovering all daemons
daemons, err := DiscoverDaemons([]string{workspacePath})
if err != nil {
return nil, err
}
for _, daemon := range daemons {
if daemon.WorkspacePath == workspacePath && daemon.Alive {
return &daemon, nil
}
}
return nil, fmt.Errorf("no daemon found for workspace: %s", workspacePath)
}
// CleanupStaleSockets removes socket files for dead daemons
func CleanupStaleSockets(daemons []DaemonInfo) (int, error) {
cleaned := 0
for _, daemon := range daemons {
if !daemon.Alive && daemon.SocketPath != "" {
if err := os.Remove(daemon.SocketPath); err != nil {
if !os.IsNotExist(err) {
return cleaned, fmt.Errorf("failed to remove stale socket %s: %w", daemon.SocketPath, err)
}
} else {
cleaned++
}
}
}
return cleaned, nil
}

View File

@@ -0,0 +1,117 @@
package daemon
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
)
func TestDiscoverDaemon(t *testing.T) {
tmpDir := t.TempDir()
workspace := filepath.Join(tmpDir, ".beads")
os.MkdirAll(workspace, 0755)
// Start daemon
dbPath := filepath.Join(workspace, "test.db")
socketPath := filepath.Join(workspace, "bd.sock")
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
server := rpc.NewServer(socketPath, store, tmpDir, dbPath)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go server.Start(ctx)
<-server.WaitReady()
defer server.Stop()
// Test discoverDaemon directly
daemon := discoverDaemon(socketPath)
if !daemon.Alive {
t.Errorf("daemon not alive: %s", daemon.Error)
}
if daemon.PID != os.Getpid() {
t.Errorf("wrong PID: expected %d, got %d", os.Getpid(), daemon.PID)
}
if daemon.UptimeSeconds <= 0 {
t.Errorf("invalid uptime: %f", daemon.UptimeSeconds)
}
if daemon.WorkspacePath != tmpDir {
t.Errorf("wrong workspace: expected %s, got %s", tmpDir, daemon.WorkspacePath)
}
}
func TestFindDaemonByWorkspace(t *testing.T) {
tmpDir := t.TempDir()
workspace := filepath.Join(tmpDir, ".beads")
os.MkdirAll(workspace, 0755)
// Start daemon
dbPath := filepath.Join(workspace, "test.db")
socketPath := filepath.Join(workspace, "bd.sock")
store, err := sqlite.New(dbPath)
if err != nil {
t.Fatalf("failed to create storage: %v", err)
}
defer store.Close()
server := rpc.NewServer(socketPath, store, tmpDir, dbPath)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go server.Start(ctx)
<-server.WaitReady()
defer server.Stop()
// Find daemon by workspace
daemon, err := FindDaemonByWorkspace(tmpDir)
if err != nil {
t.Fatalf("failed to find daemon: %v", err)
}
if daemon == nil {
t.Fatal("daemon not found")
}
if !daemon.Alive {
t.Errorf("daemon not alive: %s", daemon.Error)
}
if daemon.WorkspacePath != tmpDir {
t.Errorf("wrong workspace: expected %s, got %s", tmpDir, daemon.WorkspacePath)
}
}
func TestCleanupStaleSockets(t *testing.T) {
tmpDir := t.TempDir()
// Create stale socket file
stalePath := filepath.Join(tmpDir, "stale.sock")
if err := os.WriteFile(stalePath, []byte{}, 0644); err != nil {
t.Fatalf("failed to create stale socket: %v", err)
}
daemons := []DaemonInfo{
{
SocketPath: stalePath,
Alive: false,
},
}
cleaned, err := CleanupStaleSockets(daemons)
if err != nil {
t.Fatalf("cleanup failed: %v", err)
}
if cleaned != 1 {
t.Errorf("expected 1 cleaned, got %d", cleaned)
}
// Verify socket was removed
if _, err := os.Stat(stalePath); !os.IsNotExist(err) {
t.Error("stale socket still exists")
}
}