/{cmd,docs,internal,website}: make dolt backend explicitly single process

This commit is contained in:
Test
2026-01-20 16:51:14 -08:00
parent 869ee19f66
commit 7ed6849d19
16 changed files with 396 additions and 36 deletions

View File

@@ -47,6 +47,7 @@ Common operations:
bd daemon killall Stop all running daemons bd daemon killall Stop all running daemons
Run 'bd daemon --help' to see all subcommands.`, Run 'bd daemon --help' to see all subcommands.`,
PersistentPreRunE: guardDaemonUnsupportedForDolt,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
start, _ := cmd.Flags().GetBool("start") start, _ := cmd.Flags().GetBool("start")
stop, _ := cmd.Flags().GetBool("stop") stop, _ := cmd.Flags().GetBool("stop")

View File

@@ -9,7 +9,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/configfile"
"github.com/steveyegge/beads/internal/debug" "github.com/steveyegge/beads/internal/debug"
"github.com/steveyegge/beads/internal/lockfile" "github.com/steveyegge/beads/internal/lockfile"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
@@ -45,8 +47,38 @@ var (
sendStopSignalFn = sendStopSignal 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>.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 // shouldAutoStartDaemon checks if daemon auto-start is enabled
func shouldAutoStartDaemon() bool { 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) // Check BEADS_NO_DAEMON first (escape hatch for single-user workflows)
noDaemon := strings.ToLower(strings.TrimSpace(os.Getenv("BEADS_NO_DAEMON"))) noDaemon := strings.ToLower(strings.TrimSpace(os.Getenv("BEADS_NO_DAEMON")))
if noDaemon == "1" || noDaemon == "true" || noDaemon == "yes" || noDaemon == "on" { 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 // restartDaemonForVersionMismatch stops the old daemon and starts a new one
// Returns true if restart was successful // Returns true if restart was successful
func restartDaemonForVersionMismatch() bool { 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() pidFile, err := getPIDFilePath()
if err != nil { if err != nil {
debug.Logf("failed to get PID file path: %v", err) 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 // tryAutoStartDaemon attempts to start the daemon in the background
// Returns true if daemon was started successfully and socket is ready // Returns true if daemon was started successfully and socket is ready
func tryAutoStartDaemon(socketPath string) bool { func tryAutoStartDaemon(socketPath string) bool {
// Dolt backend is single-process-only; do not auto-start daemon.
if singleProcessOnlyBackend() {
return false
}
if !canRetryDaemonStart() { if !canRetryDaemonStart() {
debugLog("skipping auto-start due to recent failures") debugLog("skipping auto-start due to recent failures")
return false return false
@@ -351,6 +394,12 @@ func ensureLockDirectory(lockPath string) error {
} }
func startDaemonProcess(socketPath string) bool { 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) // 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 // Skip attempting to start and avoid the 5-second wait if not in git repo
if !isGitRepo() { if !isGitRepo() {
@@ -366,28 +415,11 @@ func startDaemonProcess(socketPath string) bool {
binPath = os.Args[0] binPath = os.Args[0]
} }
// IMPORTANT: Use --foreground for auto-start. // Keep sqlite auto-start behavior unchanged: start the daemon via the public
// // `bd daemon start` entrypoint (it will daemonize itself as needed).
// Rationale: args := []string{"daemon", "start"}
// - `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...) 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) setupDaemonIO(cmd)
if dbPath != "" { if dbPath != "" {
@@ -549,6 +581,9 @@ func emitVerboseWarning() {
case FallbackWorktreeSafety: case FallbackWorktreeSafety:
// Don't warn - this is expected behavior. User can configure sync-branch to enable daemon. // Don't warn - this is expected behavior. User can configure sync-branch to enable daemon.
return return
case FallbackSingleProcessOnly:
// Don't warn - daemon is intentionally disabled for single-process backends (e.g., Dolt).
return
case FallbackFlagNoDaemon: case FallbackFlagNoDaemon:
// Don't warn when user explicitly requested --no-daemon // Don't warn when user explicitly requested --no-daemon
return return

66
cmd/bd/daemon_guard.go Normal file
View File

@@ -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
}

View File

@@ -376,6 +376,13 @@ func startDaemon(interval time.Duration, autoCommit, autoPush, autoPull, localMo
os.Exit(1) 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 // Run in foreground if --foreground flag set or if we're the forked child process
if foreground || os.Getenv("BD_DAEMON_FOREGROUND") == "1" { if foreground || os.Getenv("BD_DAEMON_FOREGROUND") == "1" {
runDaemonLoop(interval, autoCommit, autoPush, autoPull, localMode, logPath, pidFile, logLevel, logJSON) runDaemonLoop(interval, autoCommit, autoPush, autoPull, localMode, logPath, pidFile, logLevel, logJSON)

View File

@@ -13,6 +13,8 @@ import (
"time" "time"
"github.com/spf13/cobra" "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/daemon"
"github.com/steveyegge/beads/internal/utils" "github.com/steveyegge/beads/internal/utils"
) )
@@ -77,6 +79,7 @@ Subcommands:
logs - View daemon logs logs - View daemon logs
killall - Stop all running daemons killall - Stop all running daemons
restart - Restart a specific daemon (not yet implemented)`, restart - Restart a specific daemon (not yet implemented)`,
PersistentPreRunE: guardDaemonUnsupportedForDolt,
} }
var daemonsListCmd = &cobra.Command{ var daemonsListCmd = &cobra.Command{
Use: "list", Use: "list",
@@ -251,6 +254,22 @@ Stops the daemon gracefully, then starts a new one.`,
os.Exit(1) os.Exit(1)
} }
workspace := targetDaemon.WorkspacePath 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 // Stop the daemon
if !jsonOutput { if !jsonOutput {
fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID) fmt.Printf("Stopping daemon for workspace: %s (PID %d)\n", workspace, targetDaemon.PID)

View File

@@ -242,7 +242,7 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) {
daemonClient = nil daemonClient = nil
// BUG: Without ensureStoreActive(), importFromJSONLInline fails // BUG: Without ensureStoreActive(), importFromJSONLInline fails
err = importFromJSONLInline(ctx, jsonlPath, false, false) err = importFromJSONLInline(ctx, jsonlPath, false, false, false)
if err == nil { if err == nil {
t.Fatal("expected importFromJSONLInline to fail when store is nil") t.Fatal("expected importFromJSONLInline to fail when store is nil")
} }
@@ -256,7 +256,7 @@ func TestImportFromJSONLInlineAfterDaemonDisconnect(t *testing.T) {
} }
// Now importFromJSONLInline should work // Now importFromJSONLInline should work
err = importFromJSONLInline(ctx, jsonlPath, false, false) err = importFromJSONLInline(ctx, jsonlPath, false, false, false)
if err != nil { if err != nil {
t.Fatalf("importFromJSONLInline failed after ensureStoreActive: %v", err) t.Fatalf("importFromJSONLInline failed after ensureStoreActive: %v", err)
} }

View File

@@ -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))
}
}

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"bufio"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
@@ -18,6 +19,7 @@ import (
"github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/factory" "github.com/steveyegge/beads/internal/storage/factory"
"github.com/steveyegge/beads/internal/types"
) )
// hookCmd is the main "bd hook" command that git hooks call into. // hookCmd is the main "bd hook" command that git hooks call into.
@@ -663,7 +665,7 @@ func hookPostMergeDolt(beadsDir string) int {
// Import JSONL to the import branch // Import JSONL to the import branch
jsonlPath := filepath.Join(beadsDir, "issues.jsonl") 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) fmt.Fprintf(os.Stderr, "Warning: could not import JSONL: %v\n", err)
// Checkout back to original branch // Checkout back to original branch
_ = doltStore.Checkout(ctx, currentBranch) _ = doltStore.Checkout(ctx, currentBranch)
@@ -831,13 +833,41 @@ func hookPostCheckout(args []string) int {
// importFromJSONLToStore imports issues from JSONL to a store. // importFromJSONLToStore imports issues from JSONL to a store.
// This is a placeholder - the actual implementation should use the store's methods. // This is a placeholder - the actual implementation should use the store's methods.
func importFromJSONLToStore(store interface{}, jsonlPath string) error { func importFromJSONLToStore(ctx context.Context, store storage.Storage, jsonlPath string) error {
_ = store // Parse JSONL into issues
_ = jsonlPath // #nosec G304 - jsonlPath is derived from beadsDir (trusted workspace path)
// Use bd sync --import-only for now f, err := os.Open(jsonlPath)
// TODO: Implement direct store import if err != nil {
cmd := exec.Command("bd", "sync", "--import-only", "--no-git-history", "--no-daemon") return err
return cmd.Run() }
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() { func init() {

View File

@@ -570,6 +570,16 @@ var rootCmd = &cobra.Command{
debug.Logf("wisp operation detected, using direct mode") 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) // Try to connect to daemon first (unless --no-daemon flag is set or worktree safety check fails)
if noDaemon { if noDaemon {
// Only set FallbackFlagNoDaemon if not already set by auto-bypass logic // Only set FallbackFlagNoDaemon if not already set by auto-bypass logic

View File

@@ -29,6 +29,7 @@ const (
FallbackConnectFailed = "connect_failed" FallbackConnectFailed = "connect_failed"
FallbackHealthFailed = "health_failed" FallbackHealthFailed = "health_failed"
FallbackWorktreeSafety = "worktree_safety" FallbackWorktreeSafety = "worktree_safety"
FallbackSingleProcessOnly = "single_process_only"
cmdDaemon = "daemon" cmdDaemon = "daemon"
cmdImport = "import" cmdImport = "import"
statusHealthy = "healthy" statusHealthy = "healthy"

View File

@@ -185,7 +185,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
fmt.Println("→ [DRY RUN] Would import from JSONL") fmt.Println("→ [DRY RUN] Would import from JSONL")
} else { } else {
fmt.Println("→ Importing from JSONL...") 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) FatalError("importing: %v", err)
} }
fmt.Println("✓ Import complete") fmt.Println("✓ Import complete")
@@ -1066,7 +1066,7 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy config
} }
// Import to database // 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) return fmt.Errorf("importing merged state: %w", err)
} }
@@ -1198,7 +1198,7 @@ func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir strin
} }
// Import to database // 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) return fmt.Errorf("importing merged state: %w", err)
} }

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"time" "time"
"github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/beads"
@@ -33,6 +34,12 @@ func importFromJSONL(ctx context.Context, jsonlPath string, renameOnImport bool,
protectLeftSnapshot = opts[1] 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 // Build args for import command
// Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues // Use --no-daemon to ensure subprocess uses direct mode, avoiding daemon connection issues
args := []string{"--no-daemon", "import", "-i", jsonlPath} 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. // 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. // The parent process's store and dbPath are used, ensuring consistent path resolution.
// (bd-ysal fix) // (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 // Verify we have an active store
if store == nil { if store == nil {
return fmt.Errorf("no database store available for inline import") return fmt.Errorf("no database store available for inline import")
@@ -107,6 +114,23 @@ func importFromJSONLInline(ctx context.Context, jsonlPath string, renameOnImport
opts := ImportOptions{ opts := ImportOptions{
RenameOnImport: renameOnImport, 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) result, err := importIssuesCore(ctx, dbPath, store, allIssues, opts)
if err != nil { if err != nil {
return fmt.Errorf("import failed: %w", err) return fmt.Errorf("import failed: %w", err)

View File

@@ -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-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 | | `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 ### Actor Identity Resolution
The actor name (used for `created_by` in issues and audit trails) is resolved in this order: The actor name (used for `created_by` in issues and audit trails) is resolved in this order:

View File

@@ -36,11 +36,12 @@ The wizard will:
- Import existing issues from git (if any) - Import existing issues from git (if any)
- Prompt to install git hooks (recommended) - Prompt to install git hooks (recommended)
- Prompt to configure git merge driver (recommended) - Prompt to configure git merge driver (recommended)
- Auto-start daemon for sync - Auto-start daemon for sync (SQLite backend only)
Notes: Notes:
- SQLite backend stores data in `.beads/beads.db`. - 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 stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
- Dolt backend runs **single-process-only**; daemon mode is disabled.
## Your First Issues ## Your First Issues

View File

@@ -149,6 +149,35 @@ const (
BackendDolt = "dolt" 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. // GetBackend returns the configured backend type, defaulting to SQLite.
func (c *Config) GetBackend() string { func (c *Config) GetBackend() string {
if c.Backend == "" { if c.Backend == "" {

View File

@@ -37,11 +37,12 @@ The wizard will:
- Import existing issues from git (if any) - Import existing issues from git (if any)
- Prompt to install git hooks (recommended) - Prompt to install git hooks (recommended)
- Prompt to configure git merge driver (recommended) - Prompt to configure git merge driver (recommended)
- Auto-start daemon for sync - Auto-start daemon for sync (SQLite backend only)
Notes: Notes:
- SQLite backend stores data in `.beads/beads.db`. - 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 stores data in `.beads/dolt/` and records `"database": "dolt"` in `.beads/metadata.json`.
- Dolt backend runs **single-process-only**; daemon mode is disabled.
## Your First Issues ## Your First Issues