package main import ( "context" "fmt" "os" "os/signal" "path/filepath" "runtime" "strings" "syscall" "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/sqlite" ) var daemonCmd = &cobra.Command{ Use: "daemon", GroupID: "sync", Short: "Manage background sync daemon", Long: `Manage the background daemon that automatically syncs issues with git remote. The daemon will: - Poll for changes at configurable intervals (default: 5 seconds) - Export pending database changes to JSONL - Auto-commit changes if --auto-commit flag set - Auto-push commits if --auto-push flag set - Pull remote changes periodically - Auto-import when remote changes detected Common operations: bd daemon --start Start the daemon (background) bd daemon --start --foreground Start in foreground (for systemd/supervisord) bd daemon --stop Stop a running daemon bd daemon --stop-all Stop ALL running bd daemons bd daemon --status Check if daemon is running bd daemon --health Check daemon health and metrics Run 'bd daemon' with no flags to see available options.`, Run: func(cmd *cobra.Command, args []string) { start, _ := cmd.Flags().GetBool("start") stop, _ := cmd.Flags().GetBool("stop") stopAll, _ := cmd.Flags().GetBool("stop-all") status, _ := cmd.Flags().GetBool("status") health, _ := cmd.Flags().GetBool("health") metrics, _ := cmd.Flags().GetBool("metrics") interval, _ := cmd.Flags().GetDuration("interval") autoCommit, _ := cmd.Flags().GetBool("auto-commit") autoPush, _ := cmd.Flags().GetBool("auto-push") autoPull, _ := cmd.Flags().GetBool("auto-pull") localMode, _ := cmd.Flags().GetBool("local") logFile, _ := cmd.Flags().GetString("log") foreground, _ := cmd.Flags().GetBool("foreground") logLevel, _ := cmd.Flags().GetString("log-level") logJSON, _ := cmd.Flags().GetBool("log-json") // If no operation flags provided, show help if !start && !stop && !stopAll && !status && !health && !metrics { _ = cmd.Help() return } // If auto-commit/auto-push flags weren't explicitly provided, read from config // GH#871: Read from config.yaml first (team-shared), then fall back to SQLite (legacy) // (skip if --stop, --status, --health, --metrics) if start && !stop && !status && !health && !metrics { // Load auto-commit/push/pull defaults from env vars, config, or sync-branch autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull) } if interval <= 0 { fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval) os.Exit(1) } pidFile, err := getPIDFilePath() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if status { showDaemonStatus(pidFile) return } if health { showDaemonHealth() return } if metrics { showDaemonMetrics() return } if stop { stopDaemon(pidFile) return } if stopAll { stopAllDaemons() return } // If we get here and --start wasn't provided, something is wrong // (should have been caught by help check above) if !start { fmt.Fprintf(os.Stderr, "Error: --start flag is required to start the daemon\n") fmt.Fprintf(os.Stderr, "Run 'bd daemon --help' to see available options\n") os.Exit(1) } // Skip daemon-running check if we're the forked child (BD_DAEMON_FOREGROUND=1) // because the check happens in the parent process before forking if os.Getenv("BD_DAEMON_FOREGROUND") != "1" { // Check if daemon is already running if isRunning, pid := isDaemonRunning(pidFile); isRunning { // Check if running daemon has compatible version socketPath := getSocketPathForPID(pidFile) if client, err := rpc.TryConnectWithTimeout(socketPath, 1*time.Second); err == nil && client != nil { health, healthErr := client.Health() _ = client.Close() // If we can check version and it's compatible, exit if healthErr == nil && health.Compatible { fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d, version %s)\n", pid, health.Version) fmt.Fprintf(os.Stderr, "Use 'bd daemon --stop' to stop it first\n") os.Exit(1) } // Version mismatch - auto-stop old daemon if healthErr == nil && !health.Compatible { fmt.Fprintf(os.Stderr, "Warning: daemon version mismatch (daemon: %s, client: %s)\n", health.Version, Version) fmt.Fprintf(os.Stderr, "Stopping old daemon and starting new one...\n") stopDaemon(pidFile) // Continue with daemon startup } } else { // Can't check version - assume incompatible fmt.Fprintf(os.Stderr, "Error: daemon already running (PID %d)\n", pid) fmt.Fprintf(os.Stderr, "Use 'bd daemon --stop' to stop it first\n") os.Exit(1) } } } // Validate --local mode constraints if localMode { if autoCommit { fmt.Fprintf(os.Stderr, "Error: --auto-commit cannot be used with --local mode\n") fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so commits are not possible\n") os.Exit(1) } if autoPush { fmt.Fprintf(os.Stderr, "Error: --auto-push cannot be used with --local mode\n") fmt.Fprintf(os.Stderr, "Hint: --local mode runs without git, so pushes are not possible\n") os.Exit(1) } } // Validate we're in a git repo (skip in local mode) if !localMode && !isGitRepo() { fmt.Fprintf(os.Stderr, "Error: not in a git repository\n") fmt.Fprintf(os.Stderr, "Hint: run 'git init' to initialize a repository, or use --local for local-only mode\n") os.Exit(1) } // Check for upstream if auto-push enabled if autoPush && !gitHasUpstream() { fmt.Fprintf(os.Stderr, "Error: no upstream configured (required for --auto-push)\n") fmt.Fprintf(os.Stderr, "Hint: git push -u origin \n") os.Exit(1) } // Warn if starting daemon in a git worktree // Ensure dbPath is set for warning if dbPath == "" { if foundDB := beads.FindDatabasePath(); foundDB != "" { dbPath = foundDB } } if dbPath != "" { warnWorktreeDaemon(dbPath) } // Start daemon if localMode { fmt.Printf("Starting bd daemon in LOCAL mode (interval: %v, no git sync)\n", interval) } else { fmt.Printf("Starting bd daemon (interval: %v, auto-commit: %v, auto-push: %v, auto-pull: %v)\n", interval, autoCommit, autoPush, autoPull) } if logFile != "" { fmt.Printf("Logging to: %s\n", logFile) } startDaemon(interval, autoCommit, autoPush, autoPull, localMode, foreground, logFile, pidFile, logLevel, logJSON) }, } func init() { daemonCmd.Flags().Bool("start", false, "Start the daemon") daemonCmd.Flags().Duration("interval", 5*time.Second, "Sync check interval") daemonCmd.Flags().Bool("auto-commit", false, "Automatically commit changes") daemonCmd.Flags().Bool("auto-push", false, "Automatically push commits") daemonCmd.Flags().Bool("auto-pull", false, "Automatically pull from remote (default: true when sync.branch configured)") daemonCmd.Flags().Bool("local", false, "Run in local-only mode (no git required, no sync)") daemonCmd.Flags().Bool("stop", false, "Stop running daemon") daemonCmd.Flags().Bool("stop-all", false, "Stop all running bd daemons") daemonCmd.Flags().Bool("status", false, "Show daemon status") daemonCmd.Flags().Bool("health", false, "Check daemon health and metrics") daemonCmd.Flags().Bool("metrics", false, "Show detailed daemon metrics") daemonCmd.Flags().String("log", "", "Log file path (default: .beads/daemon.log)") daemonCmd.Flags().Bool("foreground", false, "Run in foreground (don't daemonize)") daemonCmd.Flags().String("log-level", "info", "Log level (debug, info, warn, error)") daemonCmd.Flags().Bool("log-json", false, "Output logs in JSON format (structured logging)") daemonCmd.Flags().BoolVar(&jsonOutput, "json", false, "Output JSON format") rootCmd.AddCommand(daemonCmd) } // computeDaemonParentPID determines which parent PID the daemon should track. // When BD_DAEMON_FOREGROUND=1 (used by startDaemon for background CLI launches), // we return 0 to disable parent tracking, since the short-lived launcher // process is expected to exit immediately after spawning the daemon. // In all other cases we track the current OS parent PID. func computeDaemonParentPID() int { if os.Getenv("BD_DAEMON_FOREGROUND") == "1" { // 0 means "not tracked" in checkParentProcessAlive return 0 } return os.Getppid() } func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, localMode bool, logPath, pidFile, logLevel string, logJSON bool) { level := parseLogLevel(logLevel) logF, log := setupDaemonLogger(logPath, logJSON, level) defer func() { _ = logF.Close() }() // Set up signal-aware context for graceful shutdown ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() // Top-level panic recovery to ensure clean shutdown and diagnostics defer func() { if r := recover(); r != nil { log.Error("daemon crashed", "panic", r) // Capture stack trace stackBuf := make([]byte, 4096) stackSize := runtime.Stack(stackBuf, false) stackTrace := string(stackBuf[:stackSize]) log.Error("stack trace", "trace", stackTrace) // Write crash report to daemon-error file for user visibility var beadsDir string if dbPath != "" { beadsDir = filepath.Dir(dbPath) } else if foundDB := beads.FindDatabasePath(); foundDB != "" { beadsDir = filepath.Dir(foundDB) } if beadsDir != "" { errFile := filepath.Join(beadsDir, "daemon-error") crashReport := fmt.Sprintf("Daemon crashed at %s\n\nPanic: %v\n\nStack trace:\n%s\n", time.Now().Format(time.RFC3339), r, stackTrace) // nolint:gosec // G306: Error file needs to be readable for debugging if err := os.WriteFile(errFile, []byte(crashReport), 0644); err != nil { log.Warn("could not write crash report", "error", err) } } // Clean up PID file _ = os.Remove(pidFile) log.Info("daemon terminated after panic") } }() // Determine database path first (needed for lock file metadata) daemonDBPath := dbPath if daemonDBPath == "" { if foundDB := beads.FindDatabasePath(); foundDB != "" { daemonDBPath = foundDB } else { log.Error("no beads database found") log.Info("hint: run 'bd init' to create a database or set BEADS_DB environment variable") return // Use return instead of os.Exit to allow defers to run } } lock, err := setupDaemonLock(pidFile, daemonDBPath, log) if err != nil { return // Use return instead of os.Exit to allow defers to run } defer func() { _ = lock.Close() }() defer func() { _ = os.Remove(pidFile) }() if localMode { log.log("Daemon started in LOCAL mode (interval: %v, no git sync)", interval) } else { log.log("Daemon started (interval: %v, auto-commit: %v, auto-push: %v)", interval, autoCommit, autoPush) } // Check for multiple .db files (ambiguity error) beadsDir := filepath.Dir(daemonDBPath) // Reset backoff on daemon start (fresh start, but preserve NeedsManualSync hint) if !localMode { ResetBackoffOnDaemonStart(beadsDir) } matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) if err == nil && len(matches) > 1 { // Filter out backup files (*.backup-*.db, *.backup.db) var validDBs []string for _, match := range matches { baseName := filepath.Base(match) // Skip if it's a backup file (contains ".backup" in name) if !strings.Contains(baseName, ".backup") && baseName != "vc.db" { validDBs = append(validDBs, match) } } if len(validDBs) > 1 { errMsg := fmt.Sprintf("Error: Multiple database files found in %s:\n", beadsDir) for _, db := range validDBs { errMsg += fmt.Sprintf(" - %s\n", filepath.Base(db)) } errMsg += fmt.Sprintf("\nBeads requires a single canonical database: %s\n", beads.CanonicalDatabaseName) errMsg += "Run 'bd init' to migrate legacy databases or manually remove old databases\n" errMsg += "Or run 'bd doctor' for more diagnostics" log.log(errMsg) // Write error to file so user can see it without checking logs errFile := filepath.Join(beadsDir, "daemon-error") // nolint:gosec // G306: Error file needs to be readable for debugging if err := os.WriteFile(errFile, []byte(errMsg), 0644); err != nil { log.Warn("could not write daemon-error file", "error", err) } return // Use return instead of os.Exit to allow defers to run } } // Validate using canonical name dbBaseName := filepath.Base(daemonDBPath) if dbBaseName != beads.CanonicalDatabaseName { log.Error("non-canonical database name", "name", dbBaseName, "expected", beads.CanonicalDatabaseName) log.Info("run 'bd init' to migrate to canonical name") return // Use return instead of os.Exit to allow defers to run } log.Info("using database", "path", daemonDBPath) // Clear any previous daemon-error file on successful startup errFile := filepath.Join(beadsDir, "daemon-error") if err := os.Remove(errFile); err != nil && !os.IsNotExist(err) { log.Warn("could not remove daemon-error file", "error", err) } store, err := sqlite.New(ctx, daemonDBPath) if err != nil { log.Error("cannot open database", "error", err) return // Use return instead of os.Exit to allow defers to run } defer func() { _ = store.Close() }() // Enable freshness checking to detect external database file modifications // (e.g., when git merge replaces the database file) store.EnableFreshnessChecking() log.Info("database opened", "path", daemonDBPath, "freshness_checking", true) // Auto-upgrade .beads/.gitignore if outdated gitignoreCheck := doctor.CheckGitignore() if gitignoreCheck.Status == "warning" || gitignoreCheck.Status == "error" { log.Info("upgrading .beads/.gitignore") if err := doctor.FixGitignore(); err != nil { log.Warn("failed to upgrade .gitignore", "error", err) } else { log.Info("successfully upgraded .beads/.gitignore") } } // Hydrate from multi-repo if configured if results, err := store.HydrateFromMultiRepo(ctx); err != nil { log.Error("multi-repo hydration failed", "error", err) return // Use return instead of os.Exit to allow defers to run } else if results != nil { log.Info("multi-repo hydration complete") for repo, count := range results { log.Info("hydrated issues", "repo", repo, "count", count) } } // Validate database fingerprint (skip in local mode - no git available) if localMode { log.Info("skipping fingerprint validation (local mode)") } else if err := validateDatabaseFingerprint(ctx, store, &log); err != nil { if os.Getenv("BEADS_IGNORE_REPO_MISMATCH") != "1" { log.Error("repository fingerprint validation failed", "error", err) // Write error to daemon-error file so user sees it instead of just "daemon took too long" errFile := filepath.Join(beadsDir, "daemon-error") // nolint:gosec // G306: Error file needs to be readable for debugging if writeErr := os.WriteFile(errFile, []byte(err.Error()), 0644); writeErr != nil { log.Warn("could not write daemon-error file", "error", writeErr) } return // Use return instead of os.Exit to allow defers to run } log.Warn("repository mismatch ignored (BEADS_IGNORE_REPO_MISMATCH=1)") } // Validate schema version matches daemon version versionCtx := context.Background() dbVersion, err := store.GetMetadata(versionCtx, "bd_version") if err != nil && err.Error() != "metadata key not found: bd_version" { log.Error("failed to read database version", "error", err) return // Use return instead of os.Exit to allow defers to run } if dbVersion != "" && dbVersion != Version { log.Warn("database schema version mismatch", "db_version", dbVersion, "daemon_version", Version) log.Info("auto-upgrading database to daemon version") // Auto-upgrade database to daemon version // The daemon operates on its own database, so it should always use its own version if err := store.SetMetadata(versionCtx, "bd_version", Version); err != nil { log.Error("failed to update database version", "error", err) // Allow override via environment variable for emergencies if os.Getenv("BEADS_IGNORE_VERSION_MISMATCH") != "1" { return // Use return instead of os.Exit to allow defers to run } log.Warn("proceeding despite version update failure (BEADS_IGNORE_VERSION_MISMATCH=1)") } else { log.Info("database version updated", "version", Version) } } else if dbVersion == "" { // Old database without version metadata - set it now log.Warn("database missing version metadata", "setting_to", Version) if err := store.SetMetadata(versionCtx, "bd_version", Version); err != nil { log.Error("failed to set database version", "error", err) return // Use return instead of os.Exit to allow defers to run } } // Get workspace path (.beads directory) - beadsDir already defined above // Get actual workspace root (parent of .beads) workspacePath := filepath.Dir(beadsDir) socketPath := filepath.Join(beadsDir, "bd.sock") serverCtx, serverCancel := context.WithCancel(ctx) defer serverCancel() server, serverErrChan, err := startRPCServer(serverCtx, socketPath, store, workspacePath, daemonDBPath, log) if err != nil { return } // Choose event loop based on BEADS_DAEMON_MODE (need to determine early for SetConfig) daemonMode := os.Getenv("BEADS_DAEMON_MODE") if daemonMode == "" { daemonMode = "events" // Default to event-driven mode (production-ready as of v0.21.0) } // Set daemon configuration for status reporting server.SetConfig(autoCommit, autoPush, autoPull, localMode, interval.String(), daemonMode) // Register daemon in global registry registry, err := daemon.NewRegistry() if err != nil { log.Warn("failed to create registry", "error", err) } else { entry := daemon.RegistryEntry{ WorkspacePath: workspacePath, SocketPath: socketPath, DatabasePath: daemonDBPath, PID: os.Getpid(), Version: Version, StartedAt: time.Now(), } if err := registry.Register(entry); err != nil { log.Warn("failed to register daemon", "error", err) } else { log.Info("registered in global registry") } // Ensure we unregister on exit defer func() { if err := registry.Unregister(workspacePath, os.Getpid()); err != nil { log.Warn("failed to unregister daemon", "error", err) } }() } ticker := time.NewTicker(interval) defer ticker.Stop() // Create sync function based on mode var doSync func() if localMode { doSync = createLocalSyncFunc(ctx, store, log) } else { doSync = createSyncFunc(ctx, store, autoCommit, autoPush, log) } doSync() // Get parent PID for monitoring (exit if parent dies) parentPID := computeDaemonParentPID() log.Info("monitoring parent process", "pid", parentPID) // daemonMode already determined above for SetConfig switch daemonMode { case "events": log.Info("using event-driven mode") jsonlPath := findJSONLPath() if jsonlPath == "" { log.Error("JSONL path not found, cannot use event-driven mode") log.Info("falling back to polling mode") runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log) } else { // Event-driven mode uses separate export-only and import-only functions var doExport, doAutoImport func() if localMode { doExport = createLocalExportFunc(ctx, store, log) doAutoImport = createLocalAutoImportFunc(ctx, store, log) } else { doExport = createExportFunc(ctx, store, autoCommit, autoPush, log) doAutoImport = createAutoImportFunc(ctx, store, log) } runEventDrivenLoop(ctx, cancel, server, serverErrChan, store, jsonlPath, doExport, doAutoImport, autoPull, parentPID, log) } case "poll": log.Info("using polling mode", "interval", interval) runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log) default: log.Warn("unknown BEADS_DAEMON_MODE, defaulting to poll", "mode", daemonMode, "valid", "poll, events") runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log) } } // loadDaemonAutoSettings loads daemon sync mode settings. // // # Two Sync Modes // // Read/Write Mode (full sync): // // daemon.auto-sync: true (or BEADS_AUTO_SYNC=true) // // Enables auto-commit, auto-push, AND auto-pull. Full bidirectional sync // with team. Eliminates need for manual `bd sync`. This is the default // when sync-branch is configured. // // Read-Only Mode: // // daemon.auto-pull: true (or BEADS_AUTO_PULL=true) // // Only enables auto-pull (receive updates from team). Does NOT auto-publish // your changes. Useful for experimental work or manual review before sharing. // // # Precedence // // 1. auto-sync=true → Read/Write mode (all three ON, no exceptions) // 2. auto-sync=false → Write-side OFF, auto-pull can still be enabled // 3. auto-sync not set → Legacy compat mode: // - If either BEADS_AUTO_COMMIT/daemon.auto_commit or BEADS_AUTO_PUSH/daemon.auto_push // is enabled, treat as auto-sync=true (full read/write) // - Otherwise check auto-pull for read-only mode // 4. Fallback: all default to true when sync-branch configured // // Note: The individual auto-commit/auto-push settings are deprecated. // Use auto-sync for read/write mode, auto-pull for read-only mode. func loadDaemonAutoSettings(cmd *cobra.Command, autoCommit, autoPush, autoPull bool) (bool, bool, bool) { dbPath := beads.FindDatabasePath() if dbPath == "" { return autoCommit, autoPush, autoPull } ctx := context.Background() store, err := sqlite.New(ctx, dbPath) if err != nil { return autoCommit, autoPush, autoPull } defer func() { _ = store.Close() }() // Check if sync-branch is configured (used for defaults) syncBranch, _ := store.GetConfig(ctx, "sync.branch") hasSyncBranch := syncBranch != "" // Check unified auto-sync setting first (controls auto-commit + auto-push) unifiedAutoSync := "" if envVal := os.Getenv("BEADS_AUTO_SYNC"); envVal != "" { unifiedAutoSync = envVal } else if configVal, _ := store.GetConfig(ctx, "daemon.auto-sync"); configVal != "" { unifiedAutoSync = configVal } // Handle unified auto-sync setting if unifiedAutoSync != "" { enabled := unifiedAutoSync == "true" || unifiedAutoSync == "1" if enabled { // auto-sync=true: MASTER CONTROL, forces all three ON // Individual CLI flags are ignored - you said "full sync" autoCommit = true autoPush = true autoPull = true return autoCommit, autoPush, autoPull } // auto-sync=false: Write-side (commit/push) locked OFF // Only auto-pull can be individually enabled (for read-only mode) autoCommit = false autoPush = false // Auto-pull can still be enabled via CLI flag or individual config if cmd.Flags().Changed("auto-pull") { // Use the CLI flag value (already in autoPull) } else if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" { autoPull = envVal == "true" || envVal == "1" } else if configVal, _ := store.GetConfig(ctx, "daemon.auto-pull"); configVal != "" { autoPull = configVal == "true" } else if configVal, _ := store.GetConfig(ctx, "daemon.auto_pull"); configVal != "" { autoPull = configVal == "true" } else if hasSyncBranch { // Default auto-pull to true when sync-branch configured autoPull = true } else { autoPull = false } return autoCommit, autoPush, autoPull } // No unified setting - check legacy individual settings for backward compat // If either legacy auto-commit or auto-push is enabled, treat as auto-sync=true legacyCommit := false legacyPush := false // Check legacy auto-commit (env var or config) if envVal := os.Getenv("BEADS_AUTO_COMMIT"); envVal != "" { legacyCommit = envVal == "true" || envVal == "1" } else if configVal, _ := store.GetConfig(ctx, "daemon.auto_commit"); configVal != "" { legacyCommit = configVal == "true" } // Check legacy auto-push (env var or config) if envVal := os.Getenv("BEADS_AUTO_PUSH"); envVal != "" { legacyPush = envVal == "true" || envVal == "1" } else if configVal, _ := store.GetConfig(ctx, "daemon.auto_push"); configVal != "" { legacyPush = configVal == "true" } // If either legacy write-side option is enabled, enable full auto-sync // (backward compat: user wanted writes, so give them full sync) if legacyCommit || legacyPush { autoCommit = true autoPush = true autoPull = true return autoCommit, autoPush, autoPull } // Neither legacy write option enabled - check auto-pull for read-only mode if !cmd.Flags().Changed("auto-pull") { if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" { autoPull = envVal == "true" || envVal == "1" } else if configVal, _ := store.GetConfig(ctx, "daemon.auto-pull"); configVal != "" { autoPull = configVal == "true" } else if configVal, _ := store.GetConfig(ctx, "daemon.auto_pull"); configVal != "" { autoPull = configVal == "true" } else if hasSyncBranch { // Default auto-pull to true when sync-branch configured autoPull = true } } // Fallback: if sync-branch configured and no explicit settings, default to full sync if hasSyncBranch && !cmd.Flags().Changed("auto-commit") && !cmd.Flags().Changed("auto-push") { autoCommit = true autoPush = true autoPull = true } return autoCommit, autoPush, autoPull }