diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index c7c08a1b..13685c3b 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -47,6 +47,7 @@ Common operations: bd daemon killall Stop all running daemons Run 'bd daemon --help' to see all subcommands.`, + PersistentPreRunE: guardDaemonUnsupportedForDolt, Run: func(cmd *cobra.Command, args []string) { start, _ := cmd.Flags().GetBool("start") stop, _ := cmd.Flags().GetBool("stop") diff --git a/cmd/bd/daemon_autostart.go b/cmd/bd/daemon_autostart.go index d36256b2..17eb1b71 100644 --- a/cmd/bd/daemon_autostart.go +++ b/cmd/bd/daemon_autostart.go @@ -9,7 +9,9 @@ import ( "strings" "time" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" + "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/lockfile" "github.com/steveyegge/beads/internal/rpc" @@ -45,8 +47,38 @@ var ( sendStopSignalFn = sendStopSignal ) +// singleProcessOnlyBackend returns true if the current workspace backend is configured +// as single-process-only (currently Dolt embedded). +// +// Best-effort: if we can't determine the backend, we return false and defer to other logic. +func singleProcessOnlyBackend() bool { + // Prefer dbPath if set; it points to either .beads/.db (sqlite) or .beads/dolt (dolt dir). + beadsDir := "" + if dbPath != "" { + beadsDir = filepath.Dir(dbPath) + } else if found := beads.FindDatabasePath(); found != "" { + beadsDir = filepath.Dir(found) + } else { + beadsDir = beads.FindBeadsDir() + } + if beadsDir == "" { + return false + } + + cfg, err := configfile.Load(beadsDir) + if err != nil || cfg == nil { + return false + } + return configfile.CapabilitiesForBackend(cfg.GetBackend()).SingleProcessOnly +} + // shouldAutoStartDaemon checks if daemon auto-start is enabled func shouldAutoStartDaemon() bool { + // Dolt backend is single-process-only; do not auto-start daemon. + if singleProcessOnlyBackend() { + return false + } + // Check BEADS_NO_DAEMON first (escape hatch for single-user workflows) noDaemon := strings.ToLower(strings.TrimSpace(os.Getenv("BEADS_NO_DAEMON"))) if noDaemon == "1" || noDaemon == "true" || noDaemon == "yes" || noDaemon == "on" { @@ -70,6 +102,12 @@ func shouldAutoStartDaemon() bool { // restartDaemonForVersionMismatch stops the old daemon and starts a new one // Returns true if restart was successful func restartDaemonForVersionMismatch() bool { + // Dolt backend is single-process-only; do not restart/spawn daemon. + if singleProcessOnlyBackend() { + debugLog("single-process backend: skipping daemon restart for version mismatch") + return false + } + pidFile, err := getPIDFilePath() if err != nil { debug.Logf("failed to get PID file path: %v", err) @@ -173,6 +211,11 @@ func isDaemonRunningQuiet(pidFile string) bool { // tryAutoStartDaemon attempts to start the daemon in the background // Returns true if daemon was started successfully and socket is ready func tryAutoStartDaemon(socketPath string) bool { + // Dolt backend is single-process-only; do not auto-start daemon. + if singleProcessOnlyBackend() { + return false + } + if !canRetryDaemonStart() { debugLog("skipping auto-start due to recent failures") return false @@ -351,6 +394,12 @@ func ensureLockDirectory(lockPath string) error { } func startDaemonProcess(socketPath string) bool { + // Dolt backend is single-process-only; do not spawn a daemon. + if singleProcessOnlyBackend() { + debugLog("single-process backend: skipping daemon start") + return false + } + // Early check: daemon requires a git repository (unless --local mode) // Skip attempting to start and avoid the 5-second wait if not in git repo if !isGitRepo() { @@ -366,18 +415,9 @@ func startDaemonProcess(socketPath string) bool { binPath = os.Args[0] } - // 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"} + // Keep sqlite auto-start behavior unchanged: start the daemon via the public + // `bd daemon start` entrypoint (it will daemonize itself as needed). + args := []string{"daemon", "start"} cmd := execCommandFn(binPath, args...) // Mark this as a daemon-foreground child so we don't track/kill based on the @@ -549,6 +589,9 @@ func emitVerboseWarning() { case FallbackWorktreeSafety: // Don't warn - this is expected behavior. User can configure sync-branch to enable daemon. return + case FallbackSingleProcessOnly: + // Don't warn - daemon is intentionally disabled for single-process backends (e.g., Dolt). + return case FallbackFlagNoDaemon: // Don't warn when user explicitly requested --no-daemon return diff --git a/cmd/bd/daemon_guard.go b/cmd/bd/daemon_guard.go new file mode 100644 index 00000000..980b52a0 --- /dev/null +++ b/cmd/bd/daemon_guard.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" +) + +func singleProcessBackendHelp(backend string) string { + b := strings.TrimSpace(backend) + if b == "" { + b = "unknown" + } + // Keep this short; Cobra will prefix with "Error:". + return fmt.Sprintf("daemon mode is not supported with the %q backend (single-process only). To use daemon mode, initialize with %q (e.g. `bd init --backend sqlite`). Otherwise run commands in direct mode (default for dolt)", b, configfile.BackendSQLite) +} + +// guardDaemonUnsupportedForDolt blocks all daemon-related commands when the current +// workspace backend is Dolt. +// +// Rationale: embedded Dolt is effectively single-writer at the OS-process level. The +// daemon architecture relies on multiple processes (CLI + daemon + helper spawns), +// which can trigger lock contention and transient "read-only" failures. +// +// We still allow help output so users can discover the command surface. +func guardDaemonUnsupportedForDolt(cmd *cobra.Command, _ []string) error { + // Allow `--help` for any daemon subcommand. + if helpFlag := cmd.Flags().Lookup("help"); helpFlag != nil { + if help, _ := cmd.Flags().GetBool("help"); help { + return nil + } + } + + // Best-effort determine the active workspace backend. If we can't determine it, + // don't block (the command will likely fail later anyway). + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + // Fall back to configured dbPath if set; daemon commands often run from workspace root, + // but tests may set BEADS_DB explicitly. + if dbPath != "" { + beadsDir = filepath.Dir(dbPath) + } else if found := beads.FindDatabasePath(); found != "" { + beadsDir = filepath.Dir(found) + } + } + if beadsDir == "" { + return nil + } + + cfg, err := configfile.Load(beadsDir) + if err != nil || cfg == nil { + return nil + } + + backend := cfg.GetBackend() + if configfile.CapabilitiesForBackend(backend).SingleProcessOnly { + return fmt.Errorf("%s", singleProcessBackendHelp(backend)) + } + + return nil +} + diff --git a/cmd/bd/daemon_lifecycle.go b/cmd/bd/daemon_lifecycle.go index 67b23355..acffc288 100644 --- a/cmd/bd/daemon_lifecycle.go +++ b/cmd/bd/daemon_lifecycle.go @@ -376,6 +376,13 @@ func startDaemon(interval time.Duration, autoCommit, autoPush, autoPull, localMo os.Exit(1) } + // Guardrail: single-process backends (e.g., Dolt) must never spawn a daemon process. + // This should already be blocked by command guards, but keep it defensive. + if singleProcessOnlyBackend() { + fmt.Fprintf(os.Stderr, "Error: daemon mode is not supported for single-process backends (e.g., dolt). Hint: use sqlite backend for daemon mode, or run commands in direct mode\n") + os.Exit(1) + } + // Run in foreground if --foreground flag set or if we're the forked child process if foreground || os.Getenv("BD_DAEMON_FOREGROUND") == "1" { runDaemonLoop(interval, autoCommit, autoPush, autoPull, localMode, logPath, pidFile, logLevel, logJSON) diff --git a/cmd/bd/daemons.go b/cmd/bd/daemons.go index a8f969b5..81e51a8f 100644 --- a/cmd/bd/daemons.go +++ b/cmd/bd/daemons.go @@ -13,6 +13,8 @@ import ( "time" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" "github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/utils" ) @@ -77,6 +79,7 @@ Subcommands: logs - View daemon logs killall - Stop all running daemons restart - Restart a specific daemon (not yet implemented)`, + PersistentPreRunE: guardDaemonUnsupportedForDolt, } var daemonsListCmd = &cobra.Command{ Use: "list", @@ -251,6 +254,22 @@ Stops the daemon gracefully, then starts a new one.`, os.Exit(1) } workspace := targetDaemon.WorkspacePath + + // Guardrail: don't (re)start daemons for single-process backends (e.g., Dolt). + // This command may be run from a different workspace, so check the target workspace. + targetBeadsDir := beads.FollowRedirect(filepath.Join(workspace, ".beads")) + if cfg, err := configfile.Load(targetBeadsDir); err == nil && cfg != nil { + if configfile.CapabilitiesForBackend(cfg.GetBackend()).SingleProcessOnly { + if jsonOutput { + outputJSON(map[string]string{"error": fmt.Sprintf("daemon mode is not supported for backend %q (single-process only)", cfg.GetBackend())}) + } else { + fmt.Fprintf(os.Stderr, "Error: cannot restart daemon for workspace %s: backend %q is single-process-only\n", workspace, cfg.GetBackend()) + fmt.Fprintf(os.Stderr, "Hint: initialize the workspace with sqlite backend for daemon mode (e.g. `bd init --backend sqlite`)\n") + } + os.Exit(1) + } + } + // Stop the daemon if !jsonOutput { fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID) diff --git a/cmd/bd/direct_mode_test.go b/cmd/bd/direct_mode_test.go index 1768618f..513554af 100644 --- a/cmd/bd/direct_mode_test.go +++ b/cmd/bd/direct_mode_test.go @@ -242,7 +242,7 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) { daemonClient = nil // BUG: Without ensureStoreActive(), importFromJSONLInline fails - err = importFromJSONLInline(ctx, jsonlPath, false, false) + err = importFromJSONLInline(ctx, jsonlPath, false, false, false) if err == nil { t.Fatal("expected importFromJSONLInline to fail when store is nil") } @@ -256,7 +256,7 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) { } // Now importFromJSONLInline should work - err = importFromJSONLInline(ctx, jsonlPath, false, false) + err = importFromJSONLInline(ctx, jsonlPath, false, false, false) if err != nil { t.Fatalf("importFromJSONLInline failed after ensureStoreActive: %v", err) } diff --git a/cmd/bd/dolt_singleprocess_test.go b/cmd/bd/dolt_singleprocess_test.go new file mode 100644 index 00000000..33964929 --- /dev/null +++ b/cmd/bd/dolt_singleprocess_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeDoltWorkspace(t *testing.T, workspaceDir string) (beadsDir string, doltDir string) { + t.Helper() + beadsDir = filepath.Join(workspaceDir, ".beads") + doltDir = filepath.Join(beadsDir, "dolt") + if err := os.MkdirAll(doltDir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + + metadata := `{ + "database": "dolt", + "backend": "dolt" +}` + if err := os.WriteFile(filepath.Join(beadsDir, "metadata.json"), []byte(metadata), 0o600); err != nil { + t.Fatalf("write metadata.json: %v", err) + } + return beadsDir, doltDir +} + +func TestDoltSingleProcess_ShouldAutoStartDaemonFalse(t *testing.T) { + oldDBPath := dbPath + t.Cleanup(func() { dbPath = oldDBPath }) + dbPath = "" + + ws := t.TempDir() + beadsDir, _ := writeDoltWorkspace(t, ws) + + t.Setenv("BEADS_DIR", beadsDir) + // Ensure the finder sees a workspace root (and not the repo running tests). + oldWD, _ := os.Getwd() + _ = os.Chdir(ws) + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + if shouldAutoStartDaemon() { + t.Fatalf("expected shouldAutoStartDaemon() to be false for dolt backend") + } +} + +func TestDoltSingleProcess_TryAutoStartDoesNotCreateStartlock(t *testing.T) { + oldDBPath := dbPath + t.Cleanup(func() { dbPath = oldDBPath }) + dbPath = "" + + ws := t.TempDir() + beadsDir, _ := writeDoltWorkspace(t, ws) + t.Setenv("BEADS_DIR", beadsDir) + + socketPath := filepath.Join(ws, "bd.sock") + lockPath := socketPath + ".startlock" + + ok := tryAutoStartDaemon(socketPath) + if ok { + t.Fatalf("expected tryAutoStartDaemon() to return false for dolt backend") + } + if _, err := os.Stat(lockPath); err == nil { + t.Fatalf("expected startlock not to be created for dolt backend: %s", lockPath) + } else if !os.IsNotExist(err) { + t.Fatalf("stat startlock: %v", err) + } +} + +func TestDoltSingleProcess_DaemonGuardBlocksCommands(t *testing.T) { + oldDBPath := dbPath + t.Cleanup(func() { dbPath = oldDBPath }) + dbPath = "" + + ws := t.TempDir() + beadsDir, _ := writeDoltWorkspace(t, ws) + t.Setenv("BEADS_DIR", beadsDir) + + // Ensure help flag exists (cobra adds it during execution; for unit testing we add it explicitly). + cmd := daemonCmd + cmd.Flags().Bool("help", false, "help") + err := guardDaemonUnsupportedForDolt(cmd, nil) + if err == nil { + t.Fatalf("expected daemon guard error for dolt backend") + } + if !strings.Contains(err.Error(), "single-process") { + t.Fatalf("expected error to mention single-process, got: %v", err) + } +} + +// This test uses a helper subprocess because startDaemon calls os.Exit on failure. +func TestDoltSingleProcess_StartDaemonGuardrailExitsNonZero(t *testing.T) { + if os.Getenv("BD_TEST_HELPER_STARTDAEMON") == "1" { + // Helper mode: set up environment and invoke startDaemon (should os.Exit(1)). + ws := os.Getenv("BD_TEST_WORKSPACE") + _, doltDir := writeDoltWorkspace(t, ws) + // Ensure FindDatabasePath can resolve. + _ = os.Chdir(ws) + _ = os.Setenv("BEADS_DB", doltDir) + dbPath = "" + + pidFile := filepath.Join(ws, ".beads", "daemon.pid") + startDaemon(5*time.Second, false, false, false, false, false, "", pidFile, "info", false) + return + } + + ws := t.TempDir() + // Pre-create workspace structure so helper can just use it. + _, doltDir := writeDoltWorkspace(t, ws) + + exe, err := os.Executable() + if err != nil { + t.Fatalf("os.Executable: %v", err) + } + + cmd := exec.Command(exe, "-test.run", "^TestDoltSingleProcess_StartDaemonGuardrailExitsNonZero$", "-test.v") + cmd.Env = append(os.Environ(), + "BD_TEST_HELPER_STARTDAEMON=1", + "BD_TEST_WORKSPACE="+ws, + "BEADS_DB="+doltDir, + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected non-zero exit; output:\n%s", string(out)) + } + if !strings.Contains(string(out), "daemon mode is not supported") { + t.Fatalf("expected output to mention daemon unsupported; got:\n%s", string(out)) + } +} + diff --git a/cmd/bd/hook.go b/cmd/bd/hook.go index 92dadc5d..90e68213 100644 --- a/cmd/bd/hook.go +++ b/cmd/bd/hook.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "crypto/sha256" "encoding/hex" @@ -18,6 +19,7 @@ import ( "github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage/factory" + "github.com/steveyegge/beads/internal/types" ) // jsonlFilePaths lists all JSONL files that should be staged/tracked. @@ -670,7 +672,7 @@ func hookPostMergeDolt(beadsDir string) int { // Import JSONL to the import branch jsonlPath := filepath.Join(beadsDir, "issues.jsonl") - if err := importFromJSONLToStore(store, jsonlPath); err != nil { + if err := importFromJSONLToStore(ctx, store, jsonlPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not import JSONL: %v\n", err) // Checkout back to original branch _ = doltStore.Checkout(ctx, currentBranch) @@ -838,13 +840,41 @@ func hookPostCheckout(args []string) int { // importFromJSONLToStore imports issues from JSONL to a store. // This is a placeholder - the actual implementation should use the store's methods. -func importFromJSONLToStore(store interface{}, jsonlPath string) error { - _ = store - _ = jsonlPath - // Use bd sync --import-only for now - // TODO: Implement direct store import - cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon") - return cmd.Run() +func importFromJSONLToStore(ctx context.Context, store storage.Storage, jsonlPath string) error { + // Parse JSONL into issues + // #nosec G304 - jsonlPath is derived from beadsDir (trusted workspace path) + f, err := os.Open(jsonlPath) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + // 2MB buffer for large issues + scanner.Buffer(make([]byte, 0, 1024), 2*1024*1024) + + var allIssues []*types.Issue + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var issue types.Issue + if err := json.Unmarshal([]byte(line), &issue); err != nil { + return err + } + issue.SetDefaults() + allIssues = append(allIssues, &issue) + } + if err := scanner.Err(); err != nil { + return err + } + + // Import using shared logic (no subprocess). + // Use store.Path() as the database path (works for both sqlite and dolt). + opts := ImportOptions{} + _, err = importIssuesCore(ctx, store.Path(), store, allIssues, opts) + return err } func init() { diff --git a/cmd/bd/main.go b/cmd/bd/main.go index a9f406e6..91c0bec7 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -570,6 +570,16 @@ var rootCmd = &cobra.Command{ debug.Logf("wisp operation detected, using direct mode") } + // Dolt backend (embedded) is single-process-only; never use daemon/RPC. + // This must be checked after dbPath is resolved. + if !noDaemon && singleProcessOnlyBackend() { + noDaemon = true + daemonStatus.AutoStartEnabled = false + daemonStatus.FallbackReason = FallbackSingleProcessOnly + daemonStatus.Detail = "backend is single-process-only (dolt): daemon mode disabled; using direct mode" + debug.Logf("single-process backend detected, using direct mode") + } + // Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails) if noDaemon { // Only set FallbackFlagNoDaemon if not already set by auto-bypass logic diff --git a/cmd/bd/main_daemon.go b/cmd/bd/main_daemon.go index cf0a0c54..75640a1f 100644 --- a/cmd/bd/main_daemon.go +++ b/cmd/bd/main_daemon.go @@ -29,6 +29,7 @@ const ( FallbackConnectFailed = "connect_failed" FallbackHealthFailed = "health_failed" FallbackWorktreeSafety = "worktree_safety" + FallbackSingleProcessOnly = "single_process_only" cmdDaemon = "daemon" cmdImport = "import" statusHealthy = "healthy" diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index b508e684..58bbddb4 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -260,7 +260,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili fmt.Println("→ [DRY RUN] Would import from JSONL") } else { fmt.Println("→ Importing from JSONL...") - if err := importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory); err != nil { + if err := importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory, false); err != nil { FatalError("importing: %v", err) } fmt.Println("✓ Import complete") @@ -1077,7 +1077,7 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy config } // Import to database - if err := importFromJSONLInline(ctx, jsonlPath, false, false); err != nil { + if err := importFromJSONLInline(ctx, jsonlPath, false, false, false); err != nil { return fmt.Errorf("importing merged state: %w", err) } @@ -1209,7 +1209,7 @@ func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir strin } // Import to database - if err := importFromJSONLInline(ctx, jsonlPath, false, false); err != nil { + if err := importFromJSONLInline(ctx, jsonlPath, false, false, false); err != nil { return fmt.Errorf("importing merged state: %w", err) } diff --git a/cmd/bd/sync_import.go b/cmd/bd/sync_import.go index 95c830fe..4897e52e 100644 --- a/cmd/bd/sync_import.go +++ b/cmd/bd/sync_import.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "time" "github.com/steveyegge/beads/internal/beads" @@ -33,6 +34,12 @@ func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, protectLeftSnapshot = opts[1] } + // Guardrail: single-process backends (e.g., Dolt) must not spawn a helper `bd import` + // process while the parent holds an open store. Use inline import instead. + if singleProcessOnlyBackend() { + return importFromJSONLInline(ctx, jsonlPath, renameOnImport, noGitHistory, protectLeftSnapshot) + } + // Build args for import command // Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues args := []string{"--no-daemon", "import", "-i", jsonlPath} @@ -66,7 +73,7 @@ func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool, // This avoids path resolution issues when running from directories with .beads/redirect. // The parent process's store and dbPath are used, ensuring consistent path resolution. // (bd-ysal fix) -func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport bool, _ /* noGitHistory */ bool) error { +func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport bool, _ /* noGitHistory */ bool, protectLeftSnapshot bool) error { // Verify we have an active store if store == nil { return fmt.Errorf("no database store available for inline import") @@ -107,6 +114,23 @@ func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport opts := ImportOptions{ RenameOnImport: renameOnImport, } + + // GH#865: timestamp-aware protection for post-pull imports (bd-sync-deletion fix). + // Match `bd import --protect-left-snapshot` behavior. + if protectLeftSnapshot { + beadsDir := filepath.Dir(jsonlPath) + leftSnapshotPath := filepath.Join(beadsDir, "beads.left.jsonl") + if _, err := os.Stat(leftSnapshotPath); err == nil { + sm := NewSnapshotManager(jsonlPath) + leftTimestamps, err := sm.BuildIDToTimestampMap(leftSnapshotPath) + if err != nil { + debug.Logf("Warning: failed to read left snapshot: %v", err) + } else if len(leftTimestamps) > 0 { + opts.ProtectLocalExportIDs = leftTimestamps + fmt.Fprintf(os.Stderr, "Protecting %d issue(s) from left snapshot (timestamp-aware)\n", len(leftTimestamps)) + } + } + } result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts) if err != nil { return fmt.Errorf("import failed: %w", err) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 01d49cdf..adb0e2e3 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -57,6 +57,10 @@ Tool-level settings you can configure: | `daemon-log-max-age` | - | `BEADS_DAEMON_LOG_MAX_AGE` | `30` | Max days to keep old log files | | `daemon-log-compress` | - | `BEADS_DAEMON_LOG_COMPRESS` | `true` | Compress rotated log files | +**Backend note (SQLite vs Dolt):** +- **SQLite** supports daemon mode and auto-start. +- **Dolt (embedded)** is treated as **single-process-only**. Daemon mode and auto-start are disabled; `auto-start-daemon` has no effect. If you need daemon mode, use the SQLite backend (`bd init --backend sqlite`). + ### Actor Identity Resolution The actor name (used for `created_by` in issues and audit trails) is resolved in this order: diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index cea16766..87e45d04 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -36,7 +36,12 @@ The wizard will: - Import existing issues from git (if any) - Prompt to install git hooks (recommended) - Prompt to configure git merge driver (recommended) -- Auto-start daemon for sync +- Auto-start daemon for sync (SQLite backend only) + +Notes: +- SQLite backend stores data in `.beads/beads.db`. +- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`. +- Dolt backend runs **single-process-only**; daemon mode is disabled. Notes: - SQLite backend stores data in `.beads/beads.db`. diff --git a/internal/configfile/configfile.go b/internal/configfile/configfile.go index 14ba4774..44edfeec 100644 --- a/internal/configfile/configfile.go +++ b/internal/configfile/configfile.go @@ -149,6 +149,35 @@ const ( BackendDolt = "dolt" ) +// BackendCapabilities describes behavioral constraints for a storage backend. +// +// This is intentionally small and stable: callers should use these flags to decide +// whether to enable features like daemon/RPC/autostart and process spawning. +// +// NOTE: The embedded Dolt driver is effectively single-writer at the OS-process level. +// Even if multiple goroutines are safe within one process, multiple processes opening +// the same Dolt directory concurrently can cause lock contention and transient +// "read-only" failures. Therefore, Dolt is treated as single-process-only. +type BackendCapabilities struct { + // SingleProcessOnly indicates the backend must not be accessed from multiple + // Beads OS processes concurrently (no daemon mode, no RPC client/server split, + // no helper-process spawning). + SingleProcessOnly bool +} + +// CapabilitiesForBackend returns capabilities for a backend string. +// Unknown backends are treated conservatively as single-process-only. +func CapabilitiesForBackend(backend string) BackendCapabilities { + switch strings.TrimSpace(strings.ToLower(backend)) { + case "", BackendSQLite: + return BackendCapabilities{SingleProcessOnly: false} + case BackendDolt: + return BackendCapabilities{SingleProcessOnly: true} + default: + return BackendCapabilities{SingleProcessOnly: true} + } +} + // GetBackend returns the configured backend type, defaulting to SQLite. func (c *Config) GetBackend() string { if c.Backend == "" { diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 380b99fc..e7f9065c 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -37,7 +37,12 @@ The wizard will: - Import existing issues from git (if any) - Prompt to install git hooks (recommended) - Prompt to configure git merge driver (recommended) -- Auto-start daemon for sync +- Auto-start daemon for sync (SQLite backend only) + +Notes: +- SQLite backend stores data in `.beads/beads.db`. +- Dolt backend stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`. +- Dolt backend runs **single-process-only**; daemon mode is disabled. Notes: - SQLite backend stores data in `.beads/beads.db`.