diff --git a/.beads/beads.jsonl b/.beads/beads.jsonl index 46c20791..97650f01 100644 --- a/.beads/beads.jsonl +++ b/.beads/beads.jsonl @@ -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"}]} diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index f435b4ee..be68c863 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -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() diff --git a/cmd/bd/daemons.go b/cmd/bd/daemons.go new file mode 100644 index 00000000..dc9e86d1 --- /dev/null +++ b/cmd/bd/daemons.go @@ -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") +} diff --git a/internal/daemon/discovery.go b/internal/daemon/discovery.go new file mode 100644 index 00000000..0de36c15 --- /dev/null +++ b/internal/daemon/discovery.go @@ -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 +} diff --git a/internal/daemon/discovery_test.go b/internal/daemon/discovery_test.go new file mode 100644 index 00000000..c8a439f2 --- /dev/null +++ b/internal/daemon/discovery_test.go @@ -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") + } +}