fix(dolt): enforce single-process mode (disable daemon/autostart) (#1221)
Treat embedded Dolt as single-process-only and disable daemon/RPC/autostart when backend=dolt. Key changes: - Block bd daemon * (and bd daemons *) in Dolt workspaces with actionable messaging - Skip daemon connect/restart/autostart paths for Dolt in CLI startup - Avoid spawning helper bd import processes in Dolt mode; use inline import instead - Keep SQLite behavior unchanged, including reverting the earlier autostart --foreground change Co-authored-by: Dustin Brown <dustin@dolthub.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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>.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
|
||||
|
||||
66
cmd/bd/daemon_guard.go
Normal file
66
cmd/bd/daemon_guard.go
Normal 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
132
cmd/bd/dolt_singleprocess_test.go
Normal file
132
cmd/bd/dolt_singleprocess_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
FallbackConnectFailed = "connect_failed"
|
||||
FallbackHealthFailed = "health_failed"
|
||||
FallbackWorktreeSafety = "worktree_safety"
|
||||
FallbackSingleProcessOnly = "single_process_only"
|
||||
cmdDaemon = "daemon"
|
||||
cmdImport = "import"
|
||||
statusHealthy = "healthy"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user