From ba432847e05507e493af527921a885fea88eaf96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Tue, 20 Jan 2026 12:24:14 -0800 Subject: [PATCH] /{cmd,internal}: get dolt backend init working and allow issue creation --- cmd/bd/daemon.go | 106 ++++++++++++++++++++----------- cmd/bd/daemon_autostart.go | 21 +++++- cmd/bd/daemon_start.go | 14 +++- internal/storage/dolt/queries.go | 41 ++++++++---- 4 files changed, 130 insertions(+), 52 deletions(-) diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 82be7e14..fb4db2c2 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/storage/factory" @@ -283,6 +284,17 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local logF, log := setupDaemonLogger(logPath, logJSON, level) defer func() { _ = logF.Close() }() + writeDaemonError := func(beadsDir string, msg string) { + if beadsDir == "" || msg == "" { + return + } + errFile := filepath.Join(beadsDir, "daemon-error") + // nolint:gosec // G306: Error file needs to be readable for debugging + if err := os.WriteFile(errFile, []byte(msg), 0644); err != nil { + log.Warn("could not write daemon-error file", "error", err) + } + } + // Set up signal-aware context for graceful shutdown ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() @@ -307,13 +319,9 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local } 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) - } + writeDaemonError(beadsDir, crashReport) } // Clean up PID file @@ -331,12 +339,17 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local } else { log.Error("no beads database found") log.Info("hint: run 'bd init' to create a database or set BEADS_DB environment variable") + if dbPath != "" { + writeDaemonError(filepath.Dir(dbPath), + "Error: no beads database found\n\nHint: run 'bd init' to create a database or set BEADS_DB environment variable\n") + } return // Use return instead of os.Exit to allow defers to run } } lock, err := setupDaemonLock(pidFile, daemonDBPath, log) if err != nil { + writeDaemonError(filepath.Dir(daemonDBPath), fmt.Sprintf("Error: failed to acquire daemon lock\n\n%v\n", err)) return // Use return instead of os.Exit to allow defers to run } defer func() { _ = lock.Close() }() @@ -350,50 +363,65 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local // Check for multiple .db files (ambiguity error) beadsDir := filepath.Dir(daemonDBPath) + backend := factory.GetBackendFromConfig(beadsDir) + if backend == "" { + backend = configfile.BackendSQLite + } // 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) + // Check for multiple .db files (ambiguity error) - SQLite only. + // Dolt is directory-backed so this check is irrelevant and can be misleading. + if backend == configfile.BackendSQLite { + 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" - return // Use return instead of os.Exit to allow defers to run + 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 + // Validate using canonical name (SQLite only). + // Dolt uses a directory-backed store (typically .beads/dolt), so the "beads.db" + // basename invariant does not apply. + if backend == configfile.BackendSQLite { + 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") + writeDaemonError(beadsDir, fmt.Sprintf("Error: non-canonical database name: %s (expected %s)\n\nRun 'bd init' to migrate to canonical name.\n", + dbBaseName, beads.CanonicalDatabaseName)) + return // Use return instead of os.Exit to allow defers to run + } } log.Info("using database", "path", daemonDBPath) @@ -407,6 +435,7 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local store, err := factory.NewFromConfig(ctx, beadsDir) if err != nil { log.Error("cannot open database", "error", err) + writeDaemonError(beadsDir, fmt.Sprintf("Error: cannot open database\n\n%v\n", err)) return // Use return instead of os.Exit to allow defers to run } defer func() { _ = store.Close() }() @@ -621,6 +650,7 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local // - 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. diff --git a/cmd/bd/daemon_autostart.go b/cmd/bd/daemon_autostart.go index 1da12c25..d36256b2 100644 --- a/cmd/bd/daemon_autostart.go +++ b/cmd/bd/daemon_autostart.go @@ -366,9 +366,28 @@ func startDaemonProcess(socketPath string) bool { binPath = os.Args[0] } - args := []string{"daemon", "start"} + // IMPORTANT: Use --foreground for auto-start. + // + // Rationale: + // - `bd daemon start` (without --foreground) spawns an additional child process + // (`bd daemon --start` with BD_DAEMON_FOREGROUND=1). For Dolt, that extra + // daemonization layer can introduce startup races/lock contention (Dolt's + // LOCK acquisition timeout is 100ms). If the daemon isn't ready quickly, + // the parent falls back to direct mode and may fail to open Dolt because the + // daemon holds the write lock. + // - Here we already daemonize via SysProcAttr + stdio redirection, so a second + // layer is unnecessary. + args := []string{"daemon", "start", "--foreground"} cmd := execCommandFn(binPath, args...) + // Mark this as a daemon-foreground child so we don't track/kill based on the + // short-lived launcher process PID (see computeDaemonParentPID()). + // Also force the daemon to bind the same socket we're probing for readiness, + // avoiding any mismatch between workspace-derived paths. + cmd.Env = append(os.Environ(), + "BD_DAEMON_FOREGROUND=1", + "BD_SOCKET="+socketPath, + ) setupDaemonIO(cmd) if dbPath != "" { diff --git a/cmd/bd/daemon_start.go b/cmd/bd/daemon_start.go index 81694c47..eed6ab02 100644 --- a/cmd/bd/daemon_start.go +++ b/cmd/bd/daemon_start.go @@ -40,8 +40,18 @@ Examples: logLevel, _ := cmd.Flags().GetString("log-level") logJSON, _ := cmd.Flags().GetBool("log-json") - // Load auto-commit/push/pull defaults from env vars, config, or sync-branch - autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull) + // NOTE: Only load daemon auto-settings from the database in foreground mode. + // + // In background mode, `bd daemon start` spawns a child process to run the + // daemon loop. Opening the database here in the parent process can briefly + // hold Dolt's LOCK file long enough for the child to time out and fall back + // to read-only mode (100ms lock timeout), which can break startup. + // + // In background mode, auto-settings are loaded in the actual daemon process + // (the BD_DAEMON_FOREGROUND=1 child spawned by startDaemon). + if foreground { + autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull) + } if interval <= 0 { fmt.Fprintf(os.Stderr, "Error: interval must be positive (got %v)\n", interval) diff --git a/internal/storage/dolt/queries.go b/internal/storage/dolt/queries.go index 7aab2ca7..7d8214ee 100644 --- a/internal/storage/dolt/queries.go +++ b/internal/storage/dolt/queries.go @@ -439,25 +439,23 @@ func (s *DoltStore) GetStaleIssues(ctx context.Context, filter types.StaleFilter func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error) { stats := &types.Statistics{} - // Count by status + // Get counts (mirror SQLite semantics: exclude tombstones from TotalIssues, report separately). + // Important: COALESCE to avoid NULL scans when the table is empty. err := s.db.QueryRowContext(ctx, ` SELECT - COUNT(*) as total, - SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_count, - SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress, - SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) as closed, - SUM(CASE WHEN status = 'blocked' THEN 1 ELSE 0 END) as blocked, - SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END) as deferred, - SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END) as tombstone, - SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned + COALESCE(SUM(CASE WHEN status != 'tombstone' THEN 1 ELSE 0 END), 0) as total, + COALESCE(SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END), 0) as open_count, + COALESCE(SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END), 0) as in_progress, + COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) as closed, + COALESCE(SUM(CASE WHEN status = 'deferred' THEN 1 ELSE 0 END), 0) as deferred, + COALESCE(SUM(CASE WHEN status = 'tombstone' THEN 1 ELSE 0 END), 0) as tombstone, + COALESCE(SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END), 0) as pinned FROM issues - WHERE status != 'tombstone' `).Scan( &stats.TotalIssues, &stats.OpenIssues, &stats.InProgressIssues, &stats.ClosedIssues, - &stats.BlockedIssues, &stats.DeferredIssues, &stats.TombstoneIssues, &stats.PinnedIssues, @@ -466,6 +464,27 @@ func (s *DoltStore) GetStatistics(ctx context.Context) (*types.Statistics, error return nil, fmt.Errorf("failed to get statistics: %w", err) } + // Blocked count (same semantics as SQLite: blocked by open deps). + err = s.db.QueryRowContext(ctx, ` + SELECT COUNT(DISTINCT i.id) + FROM issues i + JOIN dependencies d ON i.id = d.issue_id + JOIN issues blocker ON d.depends_on_id = blocker.id + WHERE i.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') + AND d.type = 'blocks' + AND blocker.status IN ('open', 'in_progress', 'blocked', 'deferred', 'hooked') + `).Scan(&stats.BlockedIssues) + if err != nil { + return nil, fmt.Errorf("failed to get blocked count: %w", err) + } + + // Ready count (use the ready_issues view). + // Note: view already excludes ephemeral issues and blocked transitive deps. + err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ready_issues`).Scan(&stats.ReadyIssues) + if err != nil { + return nil, fmt.Errorf("failed to get ready count: %w", err) + } + return stats, nil }