feat(daemon): unify auto-sync config for simpler agent workflows (#904)

* feat(daemon): unify auto-sync config for simpler agent workflows

## Problem

Agents running `bd sync` at session end caused delays in the Claude Code
"event loop", slowing development. The daemon was already auto-exporting
DB→JSONL instantly, but auto-commit and auto-push weren't enabled by
default when sync-branch was configured - requiring manual `bd sync`.

Additionally, having three separate config options (auto-commit, auto-push,
auto-pull) was confusing and could get out of sync.

## Solution

Simplify to two intuitive sync modes:

1. **Read/Write Mode** (`daemon.auto-sync: true` or `BEADS_AUTO_SYNC=true`)
   - Enables auto-commit + auto-push + auto-pull
   - Full bidirectional sync - eliminates need for manual `bd sync`
   - Default when sync-branch is configured

2. **Read-Only Mode** (`daemon.auto-pull: true` or `BEADS_AUTO_PULL=true`)
   - Only receives updates from team
   - Does NOT auto-publish changes
   - Useful for experimental work or manual review before sharing

## Benefits

- **Faster agent workflows**: No more `bd sync` delays at session end
- **Simpler config**: Two modes instead of three separate toggles
- **Backward compatible**: Legacy auto_commit/auto_push settings still work
  (treated as auto-sync=true)
- **Adaptive `bd prime`**: Session close protocol adapts when daemon is
  auto-syncing (shows simplified 4-step git workflow, no `bd sync`)
- **Doctor warnings**: `bd doctor` warns about deprecated legacy config

## Changes

- cmd/bd/daemon.go: Add loadDaemonAutoSettings() with unified config logic
- cmd/bd/doctor.go: Add CheckLegacyDaemonConfig call
- cmd/bd/doctor/daemon.go: Add CheckDaemonAutoSync, CheckLegacyDaemonConfig
- cmd/bd/init_team.go: Use daemon.auto-sync in team wizard
- cmd/bd/prime.go: Detect daemon auto-sync, adapt session close protocol
- cmd/bd/prime_test.go: Add stubIsDaemonAutoSyncing for testing

* docs: add comprehensive daemon technical analysis

Add daemon-summary.md documenting the beads daemon architecture,
memory analysis (explaining the 30-35MB footprint), platform support
comparison, historical problems and fixes, and architectural guidance
for other projects implementing similar daemon patterns.

Key sections:
- Architecture deep dive with component diagrams
- Memory breakdown (SQLite WASM runtime is the main contributor)
- Platform support matrix (macOS/Linux full, Windows partial)
- Historical bugs and their fixes with reusable patterns
- Analysis of daemon usefulness without database (verdict: low value)
- Expert-reviewed improvement proposals (3 recommended, 3 skipped)
- Technical design patterns for other implementations

* feat: add cross-platform CI matrix and dual-mode test framework

Cross-Platform CI:
- Add Windows, macOS, Linux matrix to catch platform-specific bugs
- Linux: full tests with race detector and coverage
- macOS: full tests with race detector
- Windows: full tests without race detector (performance)
- Catches bugs like GH#880 (macOS path casing) and GH#387 (Windows daemon)

Dual-Mode Test Framework (cmd/bd/dual_mode_test.go):
- Runs tests in both direct mode and daemon mode
- Prevents recurring bug pattern (GH#719, GH#751, bd-fu83)
- Provides DualModeTestEnv with helper methods for common operations
- Includes 5 example tests demonstrating the pattern

Documentation:
- Add dual-mode testing section to CONTRIBUTING.md
- Document RunDualModeTest API and available helpers

Test Fixes:
- Fix sync_local_only_test.go gitPull/gitPush calls
- Add gate_no_daemon_test.go for beads-70c4 investigation

* fix(test): isolate TestFindBeadsDir tests with BEADS_DIR env var

The tests were finding the real project's .beads directory instead of
the temp directory because FindBeadsDir() walks up the directory tree.
Using BEADS_DIR env var provides proper test isolation.

* fix(test): stop daemon before running test suite guard

The test suite guard checks that tests don't modify the real repo's .beads
directory. However, a background daemon running auto-sync would touch
issues.jsonl during test execution, causing false positives.

Changes:
- Set BEADS_NO_DAEMON=1 to prevent daemon auto-start from tests
- Stop any running daemon for the repo before taking the "before" snapshot
- Uses exec to call `bd daemon --stop` to avoid import cycle issues

* chore: revert .beads/issues.jsonl to upstream/main

Per CONTRIBUTING.md, .beads/issues.jsonl should not be modified in PRs.
This commit is contained in:
Ryan
2026-01-06 12:52:19 -08:00
committed by GitHub
parent 7b0f398f11
commit ffe0dca2a3
15 changed files with 2247 additions and 137 deletions

View File

@@ -30,7 +30,7 @@ jobs:
- name: Check for .beads/issues.jsonl changes - name: Check for .beads/issues.jsonl changes
run: | run: |
if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^\.beads/issues\.jsonl$"; then if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q "^\.beads/issues\.jsonl$"; then
echo "This PR includes changes to .beads/issues.jsonl" echo "This PR includes changes to .beads/issues.jsonl"
echo "" echo ""
echo "This file is the project's issue database and should not be modified in PRs." echo "This file is the project's issue database and should not be modified in PRs."
echo "" echo ""
@@ -41,11 +41,30 @@ jobs:
echo "" echo ""
exit 1 exit 1
fi fi
echo "No .beads/issues.jsonl changes detected" echo "No .beads/issues.jsonl changes detected"
# Cross-platform test matrix
# Catches platform-specific bugs like GH#880 (macOS path casing) and GH#387 (Windows daemon)
test: test:
name: Test name: Test (${{ matrix.os }})
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
include:
# Linux: full test suite with coverage
- os: ubuntu-latest
coverage: true
test-flags: '-v -race -short -coverprofile=coverage.out'
# macOS: full test suite, no coverage (faster)
- os: macos-latest
coverage: false
test-flags: '-v -race -short'
# Windows: full test suite, no race detector (slower on Windows)
- os: windows-latest
coverage: false
test-flags: '-v -short'
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -63,66 +82,31 @@ jobs:
run: go build -v ./cmd/bd run: go build -v ./cmd/bd
- name: Test - name: Test
run: go test -v -race -short -coverprofile=coverage.out ./... run: go test ${{ matrix.test-flags }} ./...
- name: Check coverage threshold - name: Check coverage threshold
if: matrix.coverage
run: | run: |
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
MIN_COVERAGE=42 MIN_COVERAGE=42
WARN_COVERAGE=55 WARN_COVERAGE=55
echo "Coverage: $COVERAGE%" echo "Coverage: $COVERAGE%"
if (( $(echo "$COVERAGE < $MIN_COVERAGE" | bc -l) )); then if (( $(echo "$COVERAGE < $MIN_COVERAGE" | bc -l) )); then
echo "Coverage is below ${MIN_COVERAGE}% threshold" echo "Coverage is below ${MIN_COVERAGE}% threshold"
exit 1 exit 1
elif (( $(echo "$COVERAGE < $WARN_COVERAGE" | bc -l) )); then elif (( $(echo "$COVERAGE < $WARN_COVERAGE" | bc -l) )); then
echo "⚠️ Coverage is below ${WARN_COVERAGE}% (warning threshold)" echo "Coverage is below ${WARN_COVERAGE}% (warning threshold)"
else else
echo "Coverage meets threshold" echo "Coverage meets threshold"
fi fi
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v4 uses: codecov/codecov-action@v4
if: success() if: matrix.coverage && success()
with: with:
file: ./coverage.out file: ./coverage.out
fail_ci_if_error: false fail_ci_if_error: false
# Windows smoke tests only - full test suite times out (see bd-bmev)
# Linux runs comprehensive tests; Windows just verifies binary works
test-windows:
name: Test (Windows - smoke)
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Configure Git
run: |
git config --global user.name "CI Bot"
git config --global user.email "ci@beads.test"
- name: Build
run: go build -v -o bd.exe ./cmd/bd
- name: Smoke test - version
run: ./bd.exe version
- name: Smoke test - init and CRUD
run: |
./bd.exe init --quiet --prefix smoke
$output = ./bd.exe create --title "Windows smoke test" --type task
$id = ($output | Select-String -Pattern "smoke-\w+").Matches.Value
echo "Created issue: $id"
./bd.exe list
./bd.exe show $id
./bd.exe update $id --status in_progress
./bd.exe close $id
echo "All smoke tests passed!"
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -139,6 +123,7 @@ jobs:
with: with:
version: latest version: latest
args: --timeout=5m args: --timeout=5m
test-nix: test-nix:
name: Test Nix Flake name: Test Nix Flake
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -159,9 +144,9 @@ jobs:
FIRST_LINE=$(head -n 1 help.txt) FIRST_LINE=$(head -n 1 help.txt)
EXPECTED="Issues chained together like beads. A lightweight issue tracker with first-class dependency support." EXPECTED="Issues chained together like beads. A lightweight issue tracker with first-class dependency support."
if [ "$FIRST_LINE" != "$EXPECTED" ]; then if [ "$FIRST_LINE" != "$EXPECTED" ]; then
echo "First line of help.txt doesn't match expected output" echo "First line of help.txt doesn't match expected output"
echo "Expected: $EXPECTED" echo "Expected: $EXPECTED"
echo "Got: $FIRST_LINE" echo "Got: $FIRST_LINE"
exit 1 exit 1
fi fi
echo "Help text first line is correct" echo "Help text first line is correct"

View File

@@ -178,6 +178,56 @@ go test -race -coverprofile=coverage.out ./...
- Use `t.Run()` for subtests to organize related test cases - Use `t.Run()` for subtests to organize related test cases
- Mark slow tests with `if testing.Short() { t.Skip("slow test") }` - Mark slow tests with `if testing.Short() { t.Skip("slow test") }`
### Dual-Mode Testing Pattern
**IMPORTANT**: bd supports two execution modes: *direct mode* (SQLite access) and *daemon mode* (RPC via background process). Commands must work identically in both modes. To prevent bugs like GH#719, GH#751, and bd-fu83, use the dual-mode test framework for testing commands.
```go
// cmd/bd/dual_mode_test.go provides the framework
func TestMyCommand(t *testing.T) {
// This test runs TWICE: once in direct mode, once with a live daemon
RunDualModeTest(t, "my_test", func(t *testing.T, env *DualModeTestEnv) {
// Create test data using mode-agnostic helpers
issue := &types.Issue{
Title: "Test issue",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
if err := env.CreateIssue(issue); err != nil {
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
}
// Verify behavior - works in both modes
got, err := env.GetIssue(issue.ID)
if err != nil {
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
}
if got.Title != "Test issue" {
t.Errorf("[%s] wrong title: got %q", env.Mode(), got.Title)
}
})
}
```
Available `DualModeTestEnv` helper methods:
- `CreateIssue(issue)` - Create an issue
- `GetIssue(id)` - Retrieve an issue by ID
- `UpdateIssue(id, updates)` - Update issue fields
- `DeleteIssue(id, force)` - Delete (tombstone) an issue
- `AddDependency(from, to, type)` - Add a dependency
- `ListIssues(filter)` - List issues matching filter
- `GetReadyWork()` - Get issues ready for work
- `AddLabel(id, label)` - Add a label to an issue
- `Mode()` - Returns "direct" or "daemon" for error messages
Run dual-mode tests:
```bash
# Run dual-mode tests (requires integration tag)
go test -v -tags integration -run "TestDualMode" ./cmd/bd/
```
Example: Example:
```go ```go

View File

@@ -169,8 +169,8 @@ func TestCheckAndAutoImport_EmptyDatabaseNoGit(t *testing.T) {
oldJsonOutput := jsonOutput oldJsonOutput := jsonOutput
noAutoImport = false noAutoImport = false
jsonOutput = true // Suppress output jsonOutput = true // Suppress output
defer func() { defer func() {
noAutoImport = oldNoAutoImport noAutoImport = oldNoAutoImport
jsonOutput = oldJsonOutput jsonOutput = oldJsonOutput
}() }()
@@ -184,6 +184,16 @@ func TestCheckAndAutoImport_EmptyDatabaseNoGit(t *testing.T) {
} }
func TestFindBeadsDir(t *testing.T) { func TestFindBeadsDir(t *testing.T) {
// Save and clear BEADS_DIR to ensure isolation
originalEnv := os.Getenv("BEADS_DIR")
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
}()
// Create temp directory with .beads and a valid project file // Create temp directory with .beads and a valid project file
tmpDir := t.TempDir() tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads") beadsDir := filepath.Join(tmpDir, ".beads")
@@ -195,8 +205,8 @@ func TestFindBeadsDir(t *testing.T) {
t.Fatalf("Failed to create config.yaml: %v", err) t.Fatalf("Failed to create config.yaml: %v", err)
} }
// Change to tmpDir // Set BEADS_DIR to ensure test isolation (FindBeadsDir checks this first)
t.Chdir(tmpDir) os.Setenv("BEADS_DIR", beadsDir)
found := beads.FindBeadsDir() found := beads.FindBeadsDir()
if found == "" { if found == "" {
@@ -225,6 +235,16 @@ func TestFindBeadsDir_NotFound(t *testing.T) {
} }
func TestFindBeadsDir_ParentDirectory(t *testing.T) { func TestFindBeadsDir_ParentDirectory(t *testing.T) {
// Save and clear BEADS_DIR to ensure isolation
originalEnv := os.Getenv("BEADS_DIR")
defer func() {
if originalEnv != "" {
os.Setenv("BEADS_DIR", originalEnv)
} else {
os.Unsetenv("BEADS_DIR")
}
}()
// Create structure: tmpDir/.beads and tmpDir/subdir // Create structure: tmpDir/.beads and tmpDir/subdir
tmpDir := t.TempDir() tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads") beadsDir := filepath.Join(tmpDir, ".beads")
@@ -241,6 +261,9 @@ func TestFindBeadsDir_ParentDirectory(t *testing.T) {
t.Fatalf("Failed to create subdir: %v", err) t.Fatalf("Failed to create subdir: %v", err)
} }
// Set BEADS_DIR to ensure test isolation (FindBeadsDir checks this first)
os.Setenv("BEADS_DIR", beadsDir)
// Change to subdir // Change to subdir
t.Chdir(subDir) t.Chdir(subDir)

View File

@@ -14,11 +14,9 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/cmd/bd/doctor" "github.com/steveyegge/beads/cmd/bd/doctor"
"github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch"
) )
var daemonCmd = &cobra.Command{ var daemonCmd = &cobra.Command{
@@ -71,68 +69,8 @@ Run 'bd daemon' with no flags to see available options.`,
// GH#871: Read from config.yaml first (team-shared), then fall back to SQLite (legacy) // GH#871: Read from config.yaml first (team-shared), then fall back to SQLite (legacy)
// (skip if --stop, --status, --health, --metrics) // (skip if --stop, --status, --health, --metrics)
if start && !stop && !status && !health && !metrics { if start && !stop && !status && !health && !metrics {
if !cmd.Flags().Changed("auto-commit") { // Load auto-commit/push/pull defaults from env vars, config, or sync-branch
// Check config.yaml first (GH#871: team-wide settings) autoCommit, autoPush, autoPull = loadDaemonAutoSettings(cmd, autoCommit, autoPush, autoPull)
if config.GetBool("daemon.auto_commit") {
autoCommit = true
} else if dbPath := beads.FindDatabasePath(); dbPath != "" {
// Fall back to SQLite for backwards compatibility
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err == nil {
if configVal, err := store.GetConfig(ctx, "daemon.auto_commit"); err == nil && configVal == "true" {
autoCommit = true
}
_ = store.Close()
}
}
}
if !cmd.Flags().Changed("auto-push") {
// Check config.yaml first (GH#871: team-wide settings)
if config.GetBool("daemon.auto_push") {
autoPush = true
} else if dbPath := beads.FindDatabasePath(); dbPath != "" {
// Fall back to SQLite for backwards compatibility
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err == nil {
if configVal, err := store.GetConfig(ctx, "daemon.auto_push"); err == nil && configVal == "true" {
autoPush = true
}
_ = store.Close()
}
}
}
if !cmd.Flags().Changed("auto-pull") {
// Check environment variable first
if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" {
autoPull = envVal == "true" || envVal == "1"
} else if config.GetBool("daemon.auto_pull") {
// Check config.yaml (GH#871: team-wide settings)
autoPull = true
} else if dbPath := beads.FindDatabasePath(); dbPath != "" {
// Fall back to SQLite for backwards compatibility
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err == nil {
if configVal, err := store.GetConfig(ctx, "daemon.auto_pull"); err == nil {
if configVal == "true" {
autoPull = true
} else if configVal == "false" {
autoPull = false
}
} else {
// Default: auto_pull is true when sync-branch is configured
// Use syncbranch.IsConfigured() which checks env var and config.yaml
// (the common case), not just SQLite (legacy)
if syncbranch.IsConfigured() {
autoPull = true
}
}
_ = store.Close()
}
}
}
} }
if interval <= 0 { if interval <= 0 {
@@ -602,3 +540,144 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local
runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log) runEventLoop(ctx, cancel, ticker, doSync, server, serverErrChan, parentPID, log)
} }
} }
// loadDaemonAutoSettings loads daemon sync mode settings.
//
// # Two Sync Modes
//
// Read/Write Mode (full sync):
//
// daemon.auto-sync: true (or BEADS_AUTO_SYNC=true)
//
// Enables auto-commit, auto-push, AND auto-pull. Full bidirectional sync
// with team. Eliminates need for manual `bd sync`. This is the default
// when sync-branch is configured.
//
// Read-Only Mode:
//
// daemon.auto-pull: true (or BEADS_AUTO_PULL=true)
//
// Only enables auto-pull (receive updates from team). Does NOT auto-publish
// your changes. Useful for experimental work or manual review before sharing.
//
// # Precedence
//
// 1. auto-sync=true → Read/Write mode (all three ON, no exceptions)
// 2. auto-sync=false → Write-side OFF, auto-pull can still be enabled
// 3. auto-sync not set → Legacy compat mode:
// - If either BEADS_AUTO_COMMIT/daemon.auto_commit or BEADS_AUTO_PUSH/daemon.auto_push
// is enabled, treat as auto-sync=true (full read/write)
// - Otherwise check auto-pull for read-only mode
// 4. Fallback: all default to true when sync-branch configured
//
// Note: The individual auto-commit/auto-push settings are deprecated.
// Use auto-sync for read/write mode, auto-pull for read-only mode.
func loadDaemonAutoSettings(cmd *cobra.Command, autoCommit, autoPush, autoPull bool) (bool, bool, bool) {
dbPath := beads.FindDatabasePath()
if dbPath == "" {
return autoCommit, autoPush, autoPull
}
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return autoCommit, autoPush, autoPull
}
defer func() { _ = store.Close() }()
// Check if sync-branch is configured (used for defaults)
syncBranch, _ := store.GetConfig(ctx, "sync.branch")
hasSyncBranch := syncBranch != ""
// Check unified auto-sync setting first (controls auto-commit + auto-push)
unifiedAutoSync := ""
if envVal := os.Getenv("BEADS_AUTO_SYNC"); envVal != "" {
unifiedAutoSync = envVal
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto-sync"); configVal != "" {
unifiedAutoSync = configVal
}
// Handle unified auto-sync setting
if unifiedAutoSync != "" {
enabled := unifiedAutoSync == "true" || unifiedAutoSync == "1"
if enabled {
// auto-sync=true: MASTER CONTROL, forces all three ON
// Individual CLI flags are ignored - you said "full sync"
autoCommit = true
autoPush = true
autoPull = true
return autoCommit, autoPush, autoPull
}
// auto-sync=false: Write-side (commit/push) locked OFF
// Only auto-pull can be individually enabled (for read-only mode)
autoCommit = false
autoPush = false
// Auto-pull can still be enabled via CLI flag or individual config
if cmd.Flags().Changed("auto-pull") {
// Use the CLI flag value (already in autoPull)
} else if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" {
autoPull = envVal == "true" || envVal == "1"
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto-pull"); configVal != "" {
autoPull = configVal == "true"
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_pull"); configVal != "" {
autoPull = configVal == "true"
} else if hasSyncBranch {
// Default auto-pull to true when sync-branch configured
autoPull = true
} else {
autoPull = false
}
return autoCommit, autoPush, autoPull
}
// No unified setting - check legacy individual settings for backward compat
// If either legacy auto-commit or auto-push is enabled, treat as auto-sync=true
legacyCommit := false
legacyPush := false
// Check legacy auto-commit (env var or config)
if envVal := os.Getenv("BEADS_AUTO_COMMIT"); envVal != "" {
legacyCommit = envVal == "true" || envVal == "1"
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_commit"); configVal != "" {
legacyCommit = configVal == "true"
}
// Check legacy auto-push (env var or config)
if envVal := os.Getenv("BEADS_AUTO_PUSH"); envVal != "" {
legacyPush = envVal == "true" || envVal == "1"
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_push"); configVal != "" {
legacyPush = configVal == "true"
}
// If either legacy write-side option is enabled, enable full auto-sync
// (backward compat: user wanted writes, so give them full sync)
if legacyCommit || legacyPush {
autoCommit = true
autoPush = true
autoPull = true
return autoCommit, autoPush, autoPull
}
// Neither legacy write option enabled - check auto-pull for read-only mode
if !cmd.Flags().Changed("auto-pull") {
if envVal := os.Getenv("BEADS_AUTO_PULL"); envVal != "" {
autoPull = envVal == "true" || envVal == "1"
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto-pull"); configVal != "" {
autoPull = configVal == "true"
} else if configVal, _ := store.GetConfig(ctx, "daemon.auto_pull"); configVal != "" {
autoPull = configVal == "true"
} else if hasSyncBranch {
// Default auto-pull to true when sync-branch configured
autoPull = true
}
}
// Fallback: if sync-branch configured and no explicit settings, default to full sync
if hasSyncBranch && !cmd.Flags().Changed("auto-commit") && !cmd.Flags().Changed("auto-push") {
autoCommit = true
autoPush = true
autoPull = true
}
return autoCommit, autoPush, autoPull
}

View File

@@ -361,6 +361,16 @@ func runDiagnostics(path string) doctorResult {
result.OverallOK = false result.OverallOK = false
} }
// Check 8b: Daemon auto-sync (only warn, don't fail overall)
autoSyncCheck := convertWithCategory(doctor.CheckDaemonAutoSync(path), doctor.CategoryRuntime)
result.Checks = append(result.Checks, autoSyncCheck)
// Note: Don't set OverallOK = false for this - it's a performance hint, not a failure
// Check 8c: Legacy daemon config (warn about deprecated options)
legacyDaemonConfigCheck := convertWithCategory(doctor.CheckLegacyDaemonConfig(path), doctor.CategoryRuntime)
result.Checks = append(result.Checks, legacyDaemonConfigCheck)
// Note: Don't set OverallOK = false for this - deprecated options still work
// Check 9: Database-JSONL sync // Check 9: Database-JSONL sync
syncCheck := convertWithCategory(doctor.CheckDatabaseJSONLSync(path), doctor.CategoryData) syncCheck := convertWithCategory(doctor.CheckDatabaseJSONLSync(path), doctor.CategoryData)
result.Checks = append(result.Checks, syncCheck) result.Checks = append(result.Checks, syncCheck)

View File

@@ -1,6 +1,7 @@
package doctor package doctor
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"os" "os"
@@ -8,6 +9,8 @@ import (
"github.com/steveyegge/beads/internal/daemon" "github.com/steveyegge/beads/internal/daemon"
"github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/syncbranch" "github.com/steveyegge/beads/internal/syncbranch"
) )
@@ -160,3 +163,129 @@ func CheckGitSyncSetup(path string) DoctorCheck {
Category: CategoryRuntime, Category: CategoryRuntime,
} }
} }
// CheckDaemonAutoSync checks if daemon has auto-commit/auto-push enabled when
// sync-branch is configured. Missing auto-sync slows down agent workflows.
func CheckDaemonAutoSync(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
socketPath := filepath.Join(beadsDir, "bd.sock")
// Check if daemon is running
if _, err := os.Stat(socketPath); os.IsNotExist(err) {
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusOK,
Message: "Daemon not running (will use defaults on next start)",
}
}
// Check if sync-branch is configured
ctx := context.Background()
dbPath := filepath.Join(beadsDir, "beads.db")
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusOK,
Message: "Could not check config (database unavailable)",
}
}
defer func() { _ = store.Close() }()
syncBranch, _ := store.GetConfig(ctx, "sync.branch")
if syncBranch == "" {
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusOK,
Message: "No sync-branch configured (auto-sync not applicable)",
}
}
// Sync-branch is configured - check daemon's auto-commit/auto-push status
client, err := rpc.TryConnect(socketPath)
if err != nil || client == nil {
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusWarning,
Message: "Could not connect to daemon to check auto-sync status",
}
}
defer func() { _ = client.Close() }()
status, err := client.Status()
if err != nil {
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusWarning,
Message: "Could not get daemon status",
Detail: err.Error(),
}
}
if !status.AutoCommit || !status.AutoPush {
var missing []string
if !status.AutoCommit {
missing = append(missing, "auto-commit")
}
if !status.AutoPush {
missing = append(missing, "auto-push")
}
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusWarning,
Message: fmt.Sprintf("Daemon running without %v (slows agent workflows)", missing),
Detail: "With sync-branch configured, auto-commit and auto-push should be enabled",
Fix: "Restart daemon: bd daemon --stop && bd daemon --start",
}
}
return DoctorCheck{
Name: "Daemon Auto-Sync",
Status: StatusOK,
Message: "Auto-commit and auto-push enabled",
}
}
// CheckLegacyDaemonConfig checks for deprecated daemon config options and
// encourages migration to the unified daemon.auto-sync setting.
func CheckLegacyDaemonConfig(path string) DoctorCheck {
beadsDir := filepath.Join(path, ".beads")
dbPath := filepath.Join(beadsDir, "beads.db")
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
return DoctorCheck{
Name: "Daemon Config",
Status: StatusOK,
Message: "Could not check config (database unavailable)",
}
}
defer func() { _ = store.Close() }()
// Check for deprecated individual settings
var legacySettings []string
if val, _ := store.GetConfig(ctx, "daemon.auto_commit"); val != "" {
legacySettings = append(legacySettings, "daemon.auto_commit")
}
if val, _ := store.GetConfig(ctx, "daemon.auto_push"); val != "" {
legacySettings = append(legacySettings, "daemon.auto_push")
}
if len(legacySettings) > 0 {
return DoctorCheck{
Name: "Daemon Config",
Status: StatusWarning,
Message: fmt.Sprintf("Deprecated config options found: %v", legacySettings),
Detail: "These options still work but are deprecated. Use daemon.auto-sync for read/write mode or daemon.auto-pull for read-only mode.",
Fix: "Run: bd config delete daemon.auto_commit && bd config delete daemon.auto_push && bd config set daemon.auto-sync true",
}
}
return DoctorCheck{
Name: "Daemon Config",
Status: StatusOK,
Message: "Using current config format",
}
}

View File

@@ -1,12 +1,14 @@
package doctor package doctor
import ( import (
"context"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/steveyegge/beads/internal/git" "github.com/steveyegge/beads/internal/git"
"github.com/steveyegge/beads/internal/storage/sqlite"
) )
func TestCheckDaemonStatus(t *testing.T) { func TestCheckDaemonStatus(t *testing.T) {
@@ -79,7 +81,7 @@ func TestCheckGitSyncSetup(t *testing.T) {
}() }()
// Initialize git repo // Initialize git repo
cmd := exec.Command("git", "init") cmd := exec.Command("git", "init", "--initial-branch=main")
cmd.Dir = tmpDir cmd.Dir = tmpDir
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
t.Fatalf("Failed to init git repo: %v", err) t.Fatalf("Failed to init git repo: %v", err)
@@ -104,3 +106,88 @@ func TestCheckGitSyncSetup(t *testing.T) {
} }
}) })
} }
func TestCheckDaemonAutoSync(t *testing.T) {
t.Run("no daemon socket", func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
check := CheckDaemonAutoSync(tmpDir)
if check.Status != StatusOK {
t.Errorf("Status = %q, want %q", check.Status, StatusOK)
}
if check.Message != "Daemon not running (will use defaults on next start)" {
t.Errorf("Message = %q, want 'Daemon not running...'", check.Message)
}
})
t.Run("no sync-branch configured", func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database without sync-branch config
dbPath := filepath.Join(beadsDir, "beads.db")
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatal(err)
}
defer func() { _ = store.Close() }()
// Create a fake socket file to simulate daemon running
socketPath := filepath.Join(beadsDir, "bd.sock")
if err := os.WriteFile(socketPath, []byte{}, 0600); err != nil {
t.Fatal(err)
}
check := CheckDaemonAutoSync(tmpDir)
// Should return OK because no sync-branch means auto-sync not applicable
if check.Status != StatusOK {
t.Errorf("Status = %q, want %q", check.Status, StatusOK)
}
if check.Message != "No sync-branch configured (auto-sync not applicable)" {
t.Errorf("Message = %q, want 'No sync-branch...'", check.Message)
}
})
t.Run("sync-branch configured but cannot connect", func(t *testing.T) {
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.Mkdir(beadsDir, 0755); err != nil {
t.Fatal(err)
}
// Create database with sync-branch config
dbPath := filepath.Join(beadsDir, "beads.db")
ctx := context.Background()
store, err := sqlite.New(ctx, dbPath)
if err != nil {
t.Fatal(err)
}
if err := store.SetConfig(ctx, "sync.branch", "beads-sync"); err != nil {
t.Fatal(err)
}
_ = store.Close()
// Create a fake socket file (not a real daemon)
socketPath := filepath.Join(beadsDir, "bd.sock")
if err := os.WriteFile(socketPath, []byte{}, 0600); err != nil {
t.Fatal(err)
}
check := CheckDaemonAutoSync(tmpDir)
// Should return warning because can't connect to fake socket
if check.Status != StatusWarning {
t.Errorf("Status = %q, want %q", check.Status, StatusWarning)
}
})
}

792
cmd/bd/dual_mode_test.go Normal file
View File

@@ -0,0 +1,792 @@
// dual_mode_test.go - Test framework for ensuring commands work in both daemon and direct modes.
//
// PROBLEM:
// Multiple bugs have occurred where commands work in one mode but not the other:
// - GH#751: bd graph accessed nil store in daemon mode
// - GH#719: bd create -f bypassed daemon RPC
// - bd-fu83: relate/duplicate used direct store when daemon was running
//
// SOLUTION:
// This file provides a reusable test pattern that runs the same test logic
// in both direct mode (--no-daemon) and daemon mode, ensuring commands
// behave identically regardless of which mode they're running in.
//
// USAGE:
//
// func TestCreateCommand(t *testing.T) {
// RunDualModeTest(t, "create basic issue", func(t *testing.T, env *DualModeTestEnv) {
// // Create an issue - this code runs twice: once in direct mode, once with daemon
// issue := &types.Issue{
// Title: "Test issue",
// IssueType: types.TypeTask,
// Status: types.StatusOpen,
// Priority: 2,
// }
// err := env.CreateIssue(issue)
// if err != nil {
// t.Fatalf("CreateIssue failed: %v", err)
// }
//
// // Verify issue was created
// got, err := env.GetIssue(issue.ID)
// if err != nil {
// t.Fatalf("GetIssue failed: %v", err)
// }
// if got.Title != "Test issue" {
// t.Errorf("expected title 'Test issue', got %q", got.Title)
// }
// })
// }
//
// The test framework handles:
// - Setting up isolated test environments (temp dirs, databases)
// - Starting/stopping daemon for daemon mode tests
// - Saving/restoring global state between runs
// - Providing a unified API for common operations
//go:build integration
// +build integration
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
)
// TestMode indicates which mode the test is running in
type TestMode string
const (
// DirectMode: Commands access SQLite directly (--no-daemon)
DirectMode TestMode = "direct"
// DaemonMode: Commands communicate via RPC to a background daemon
DaemonMode TestMode = "daemon"
)
// DualModeTestEnv provides a unified test environment that works in both modes.
// Tests should use this interface rather than accessing global state directly.
type DualModeTestEnv struct {
t *testing.T
mode TestMode
tmpDir string
beadsDir string
dbPath string
socketPath string
// Direct mode resources
store *sqlite.SQLiteStorage
// Daemon mode resources
client *rpc.Client
server *rpc.Server
serverDone chan error
// Context for operations
ctx context.Context
cancel context.CancelFunc
}
// Mode returns the current test mode (direct or daemon)
func (e *DualModeTestEnv) Mode() TestMode {
return e.mode
}
// Context returns the test context
func (e *DualModeTestEnv) Context() context.Context {
return e.ctx
}
// Store returns the direct store (only valid in DirectMode)
// For mode-agnostic operations, use the helper methods instead.
func (e *DualModeTestEnv) Store() *sqlite.SQLiteStorage {
if e.mode != DirectMode {
e.t.Fatal("Store() called in daemon mode - use helper methods instead")
}
return e.store
}
// Client returns the RPC client (only valid in DaemonMode)
// For mode-agnostic operations, use the helper methods instead.
func (e *DualModeTestEnv) Client() *rpc.Client {
if e.mode != DaemonMode {
e.t.Fatal("Client() called in direct mode - use helper methods instead")
}
return e.client
}
// CreateIssue creates an issue in either mode
func (e *DualModeTestEnv) CreateIssue(issue *types.Issue) error {
if e.mode == DirectMode {
return e.store.CreateIssue(e.ctx, issue, "test")
}
// Daemon mode: use RPC
args := &rpc.CreateArgs{
Title: issue.Title,
Description: issue.Description,
IssueType: string(issue.IssueType),
Priority: issue.Priority,
}
resp, err := e.client.Create(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("create failed: %s", resp.Error)
}
// Parse response to get the created issue ID
// The RPC response contains the created issue as JSON
var createdIssue types.Issue
if err := json.Unmarshal(resp.Data, &createdIssue); err != nil {
return fmt.Errorf("failed to parse created issue: %w", err)
}
issue.ID = createdIssue.ID
return nil
}
// GetIssue retrieves an issue by ID in either mode
func (e *DualModeTestEnv) GetIssue(id string) (*types.Issue, error) {
if e.mode == DirectMode {
return e.store.GetIssue(e.ctx, id)
}
// Daemon mode: use RPC
args := &rpc.ShowArgs{ID: id}
resp, err := e.client.Show(args)
if err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("show failed: %s", resp.Error)
}
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err != nil {
return nil, fmt.Errorf("failed to parse issue: %w", err)
}
return &issue, nil
}
// UpdateIssue updates an issue in either mode
func (e *DualModeTestEnv) UpdateIssue(id string, updates map[string]interface{}) error {
if e.mode == DirectMode {
return e.store.UpdateIssue(e.ctx, id, updates, "test")
}
// Daemon mode: use RPC - convert map to UpdateArgs fields
args := &rpc.UpdateArgs{ID: id}
// Map common fields to their RPC counterparts
if title, ok := updates["title"].(string); ok {
args.Title = &title
}
if status, ok := updates["status"].(types.Status); ok {
s := string(status)
args.Status = &s
}
if statusStr, ok := updates["status"].(string); ok {
args.Status = &statusStr
}
if priority, ok := updates["priority"].(int); ok {
args.Priority = &priority
}
if desc, ok := updates["description"].(string); ok {
args.Description = &desc
}
resp, err := e.client.Update(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("update failed: %s", resp.Error)
}
return nil
}
// DeleteIssue marks an issue as deleted (tombstoned) in either mode
func (e *DualModeTestEnv) DeleteIssue(id string, force bool) error {
if e.mode == DirectMode {
updates := map[string]interface{}{
"status": types.StatusTombstone,
}
return e.store.UpdateIssue(e.ctx, id, updates, "test")
}
// Daemon mode: use RPC
args := &rpc.DeleteArgs{
IDs: []string{id},
Force: force,
DryRun: false,
Reason: "test deletion",
}
resp, err := e.client.Delete(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("delete failed: %s", resp.Error)
}
return nil
}
// AddDependency adds a dependency in either mode
func (e *DualModeTestEnv) AddDependency(issueID, dependsOnID string, depType types.DependencyType) error {
if e.mode == DirectMode {
dep := &types.Dependency{
IssueID: issueID,
DependsOnID: dependsOnID,
Type: depType,
}
return e.store.AddDependency(e.ctx, dep, "test")
}
// Daemon mode: use RPC
args := &rpc.DepAddArgs{
FromID: issueID,
ToID: dependsOnID,
DepType: string(depType),
}
resp, err := e.client.AddDependency(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("add dependency failed: %s", resp.Error)
}
return nil
}
// ListIssues returns issues matching the filter in either mode
func (e *DualModeTestEnv) ListIssues(filter types.IssueFilter) ([]*types.Issue, error) {
if e.mode == DirectMode {
return e.store.SearchIssues(e.ctx, "", filter)
}
// Daemon mode: use RPC - convert filter to ListArgs
args := &rpc.ListArgs{}
if filter.Status != nil {
args.Status = string(*filter.Status)
}
if filter.Priority != nil {
args.Priority = filter.Priority
}
if filter.IssueType != nil {
args.IssueType = string(*filter.IssueType)
}
resp, err := e.client.List(args)
if err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("list failed: %s", resp.Error)
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
return nil, fmt.Errorf("failed to parse issues: %w", err)
}
return issues, nil
}
// GetReadyWork returns issues ready for work in either mode
func (e *DualModeTestEnv) GetReadyWork() ([]*types.Issue, error) {
if e.mode == DirectMode {
return e.store.GetReadyWork(e.ctx, types.WorkFilter{})
}
// Daemon mode: use RPC
args := &rpc.ReadyArgs{}
resp, err := e.client.Ready(args)
if err != nil {
return nil, err
}
if !resp.Success {
return nil, fmt.Errorf("ready failed: %s", resp.Error)
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
return nil, fmt.Errorf("failed to parse issues: %w", err)
}
return issues, nil
}
// AddLabel adds a label to an issue in either mode
func (e *DualModeTestEnv) AddLabel(issueID, label string) error {
if e.mode == DirectMode {
return e.store.AddLabel(e.ctx, issueID, label, "test")
}
// Daemon mode: use RPC
args := &rpc.LabelAddArgs{
ID: issueID,
Label: label,
}
resp, err := e.client.AddLabel(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("add label failed: %s", resp.Error)
}
return nil
}
// RemoveLabel removes a label from an issue in either mode
func (e *DualModeTestEnv) RemoveLabel(issueID, label string) error {
if e.mode == DirectMode {
return e.store.RemoveLabel(e.ctx, issueID, label, "test")
}
// Daemon mode: use RPC
args := &rpc.LabelRemoveArgs{
ID: issueID,
Label: label,
}
resp, err := e.client.RemoveLabel(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("remove label failed: %s", resp.Error)
}
return nil
}
// AddComment adds a comment to an issue in either mode
func (e *DualModeTestEnv) AddComment(issueID, text string) error {
if e.mode == DirectMode {
return e.store.AddComment(e.ctx, issueID, "test", text)
}
// Daemon mode: use RPC
args := &rpc.CommentAddArgs{
ID: issueID,
Author: "test",
Text: text,
}
resp, err := e.client.AddComment(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("add comment failed: %s", resp.Error)
}
return nil
}
// CloseIssue closes an issue with a reason in either mode
func (e *DualModeTestEnv) CloseIssue(id, reason string) error {
if e.mode == DirectMode {
updates := map[string]interface{}{
"status": types.StatusClosed,
"close_reason": reason,
}
return e.store.UpdateIssue(e.ctx, id, updates, "test")
}
// Daemon mode: use RPC
args := &rpc.CloseArgs{
ID: id,
Reason: reason,
}
resp, err := e.client.CloseIssue(args)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("close failed: %s", resp.Error)
}
return nil
}
// TmpDir returns the test temporary directory
func (e *DualModeTestEnv) TmpDir() string {
return e.tmpDir
}
// BeadsDir returns the .beads directory path
func (e *DualModeTestEnv) BeadsDir() string {
return e.beadsDir
}
// DBPath returns the database file path
func (e *DualModeTestEnv) DBPath() string {
return e.dbPath
}
// DualModeTestFunc is the function signature for tests that run in both modes
type DualModeTestFunc func(t *testing.T, env *DualModeTestEnv)
// RunDualModeTest runs a test function in both direct mode and daemon mode.
// This ensures the tested behavior works correctly regardless of which mode
// the CLI is operating in.
func RunDualModeTest(t *testing.T, name string, testFn DualModeTestFunc) {
t.Helper()
// Run in direct mode
t.Run(name+"_direct", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping dual-mode test in short mode")
}
env := setupDirectModeEnv(t)
testFn(t, env)
})
// Run in daemon mode
t.Run(name+"_daemon", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping dual-mode test in short mode")
}
env := setupDaemonModeEnv(t)
testFn(t, env)
})
}
// RunDirectModeOnly runs a test only in direct mode.
// Use sparingly - prefer RunDualModeTest for most tests.
func RunDirectModeOnly(t *testing.T, name string, testFn DualModeTestFunc) {
t.Helper()
t.Run(name, func(t *testing.T) {
env := setupDirectModeEnv(t)
testFn(t, env)
})
}
// RunDaemonModeOnly runs a test only in daemon mode.
// Use sparingly - prefer RunDualModeTest for most tests.
func RunDaemonModeOnly(t *testing.T, name string, testFn DualModeTestFunc) {
t.Helper()
t.Run(name, func(t *testing.T) {
if testing.Short() {
t.Skip("skipping daemon test in short mode")
}
env := setupDaemonModeEnv(t)
testFn(t, env)
})
}
// setupDirectModeEnv creates a test environment for direct mode testing
func setupDirectModeEnv(t *testing.T) *DualModeTestEnv {
t.Helper()
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
dbPath := filepath.Join(beadsDir, "beads.db")
store := newTestStore(t, dbPath)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
env := &DualModeTestEnv{
t: t,
mode: DirectMode,
tmpDir: tmpDir,
beadsDir: beadsDir,
dbPath: dbPath,
store: store,
ctx: ctx,
cancel: cancel,
}
return env
}
// setupDaemonModeEnv creates a test environment with a running daemon
func setupDaemonModeEnv(t *testing.T) *DualModeTestEnv {
t.Helper()
tmpDir := makeSocketTempDir(t)
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatalf("failed to create .beads dir: %v", err)
}
// Initialize git repo (required for daemon)
initTestGitRepo(t, tmpDir)
dbPath := filepath.Join(beadsDir, "beads.db")
socketPath := filepath.Join(beadsDir, "bd.sock")
store := newTestStore(t, dbPath)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
// Create daemon logger
log := daemonLogger{logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo}))}
// Start RPC server
server, serverErrChan, err := startRPCServer(ctx, socketPath, store, tmpDir, dbPath, log)
if err != nil {
cancel()
t.Fatalf("failed to start RPC server: %v", err)
}
// Wait for server to be ready
select {
case <-server.WaitReady():
// Server is ready
case <-time.After(5 * time.Second):
cancel()
t.Fatal("server did not become ready within 5 seconds")
}
// Connect RPC client
client, err := rpc.TryConnect(socketPath)
if err != nil || client == nil {
cancel()
server.Stop()
t.Fatalf("failed to connect RPC client: %v", err)
}
// Consume server errors in background
serverDone := make(chan error, 1)
go func() {
select {
case err := <-serverErrChan:
serverDone <- err
case <-ctx.Done():
serverDone <- ctx.Err()
}
}()
env := &DualModeTestEnv{
t: t,
mode: DaemonMode,
tmpDir: tmpDir,
beadsDir: beadsDir,
dbPath: dbPath,
socketPath: socketPath,
store: store,
client: client,
server: server,
serverDone: serverDone,
ctx: ctx,
cancel: cancel,
}
// Register cleanup
t.Cleanup(func() {
if client != nil {
client.Close()
}
if server != nil {
server.Stop()
}
cancel()
os.RemoveAll(tmpDir)
})
return env
}
// ============================================================================
// Example dual-mode tests demonstrating the pattern
// ============================================================================
// TestDualMode_CreateAndRetrieveIssue demonstrates the basic dual-mode test pattern
func TestDualMode_CreateAndRetrieveIssue(t *testing.T) {
RunDualModeTest(t, "create_and_retrieve", func(t *testing.T, env *DualModeTestEnv) {
// This code runs twice: once in direct mode, once with daemon
issue := &types.Issue{
Title: "Test issue",
Description: "Test description",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
// Create issue
if err := env.CreateIssue(issue); err != nil {
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
}
if issue.ID == "" {
t.Fatalf("[%s] issue ID not set after creation", env.Mode())
}
// Retrieve issue
got, err := env.GetIssue(issue.ID)
if err != nil {
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
}
// Verify
if got.Title != "Test issue" {
t.Errorf("[%s] expected title 'Test issue', got %q", env.Mode(), got.Title)
}
if got.Status != types.StatusOpen {
t.Errorf("[%s] expected status 'open', got %q", env.Mode(), got.Status)
}
})
}
// TestDualMode_UpdateIssue tests updating issues works in both modes
func TestDualMode_UpdateIssue(t *testing.T) {
RunDualModeTest(t, "update_issue", func(t *testing.T, env *DualModeTestEnv) {
// Create issue
issue := &types.Issue{
Title: "Original title",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
if err := env.CreateIssue(issue); err != nil {
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
}
// Update issue
updates := map[string]interface{}{
"title": "Updated title",
"status": types.StatusInProgress,
}
if err := env.UpdateIssue(issue.ID, updates); err != nil {
t.Fatalf("[%s] UpdateIssue failed: %v", env.Mode(), err)
}
// Verify update
got, err := env.GetIssue(issue.ID)
if err != nil {
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
}
if got.Title != "Updated title" {
t.Errorf("[%s] expected title 'Updated title', got %q", env.Mode(), got.Title)
}
if got.Status != types.StatusInProgress {
t.Errorf("[%s] expected status 'in_progress', got %q", env.Mode(), got.Status)
}
})
}
// TestDualMode_Dependencies tests dependency operations in both modes
func TestDualMode_Dependencies(t *testing.T) {
RunDualModeTest(t, "dependencies", func(t *testing.T, env *DualModeTestEnv) {
// Create two issues
blocker := &types.Issue{
Title: "Blocker issue",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 1,
}
blocked := &types.Issue{
Title: "Blocked issue",
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: 2,
}
if err := env.CreateIssue(blocker); err != nil {
t.Fatalf("[%s] CreateIssue(blocker) failed: %v", env.Mode(), err)
}
if err := env.CreateIssue(blocked); err != nil {
t.Fatalf("[%s] CreateIssue(blocked) failed: %v", env.Mode(), err)
}
// Add blocking dependency
if err := env.AddDependency(blocked.ID, blocker.ID, types.DepBlocks); err != nil {
t.Fatalf("[%s] AddDependency failed: %v", env.Mode(), err)
}
// Verify blocked issue is not in ready queue
ready, err := env.GetReadyWork()
if err != nil {
t.Fatalf("[%s] GetReadyWork failed: %v", env.Mode(), err)
}
for _, r := range ready {
if r.ID == blocked.ID {
t.Errorf("[%s] blocked issue should not be in ready queue", env.Mode())
}
}
// Verify blocker is in ready queue (it has no blockers)
found := false
for _, r := range ready {
if r.ID == blocker.ID {
found = true
break
}
}
if !found {
t.Errorf("[%s] blocker issue should be in ready queue", env.Mode())
}
})
}
// TestDualMode_ListIssues tests listing issues works in both modes
func TestDualMode_ListIssues(t *testing.T) {
RunDualModeTest(t, "list_issues", func(t *testing.T, env *DualModeTestEnv) {
// Create multiple issues
for i := 0; i < 3; i++ {
issue := &types.Issue{
Title: fmt.Sprintf("Issue %d", i),
IssueType: types.TypeTask,
Status: types.StatusOpen,
Priority: i + 1,
}
if err := env.CreateIssue(issue); err != nil {
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
}
}
// List all issues
issues, err := env.ListIssues(types.IssueFilter{})
if err != nil {
t.Fatalf("[%s] ListIssues failed: %v", env.Mode(), err)
}
if len(issues) != 3 {
t.Errorf("[%s] expected 3 issues, got %d", env.Mode(), len(issues))
}
})
}
// TestDualMode_Labels tests label operations in both modes
func TestDualMode_Labels(t *testing.T) {
RunDualModeTest(t, "labels", func(t *testing.T, env *DualModeTestEnv) {
// Create issue
issue := &types.Issue{
Title: "Issue with labels",
IssueType: types.TypeBug,
Status: types.StatusOpen,
Priority: 1,
}
if err := env.CreateIssue(issue); err != nil {
t.Fatalf("[%s] CreateIssue failed: %v", env.Mode(), err)
}
// Add label
if err := env.AddLabel(issue.ID, "critical"); err != nil {
t.Fatalf("[%s] AddLabel failed: %v", env.Mode(), err)
}
// Verify label was added by fetching the issue
got, err := env.GetIssue(issue.ID)
if err != nil {
t.Fatalf("[%s] GetIssue failed: %v", env.Mode(), err)
}
// Note: Label verification depends on whether the Show RPC returns labels
// This test primarily verifies the AddLabel operation doesn't error
_ = got // Use the retrieved issue for future label verification
})
}

View File

@@ -119,16 +119,16 @@ func runTeamWizard(ctx context.Context, store storage.Storage) error {
if autoSync { if autoSync {
// GH#871: Write to config.yaml for team-wide settings (version controlled) // GH#871: Write to config.yaml for team-wide settings (version controlled)
if err := config.SetYamlConfig("daemon.auto_commit", "true"); err != nil { // Use unified auto-sync config (replaces individual auto_commit/auto_push/auto_pull)
return fmt.Errorf("failed to enable auto-commit: %w", err) if err := config.SetYamlConfig("daemon.auto-sync", "true"); err != nil {
} return fmt.Errorf("failed to enable auto-sync: %w", err)
if err := config.SetYamlConfig("daemon.auto_push", "true"); err != nil {
return fmt.Errorf("failed to enable auto-push: %w", err)
} }
fmt.Printf("%s Auto-sync enabled\n", ui.RenderPass("✓")) fmt.Printf("%s Auto-sync enabled\n", ui.RenderPass("✓"))
} else { } else {
if err := config.SetYamlConfig("daemon.auto-sync", "false"); err != nil {
return fmt.Errorf("failed to disable auto-sync: %w", err)
}
fmt.Printf("%s Auto-sync disabled (manual sync with 'bd sync')\n", ui.RenderWarn("⚠")) fmt.Printf("%s Auto-sync disabled (manual sync with 'bd sync')\n", ui.RenderWarn("⚠"))
} }
@@ -172,7 +172,7 @@ func runTeamWizard(ctx context.Context, store storage.Storage) error {
fmt.Println() fmt.Println()
fmt.Printf("Try it: %s\n", ui.RenderAccent("bd create \"Team planning issue\" -p 2")) fmt.Printf("Try it: %s\n", ui.RenderAccent("bd create \"Team planning issue\" -p 2"))
fmt.Println() fmt.Println()
if protectedMain { if protectedMain {
fmt.Println("Next steps:") fmt.Println("Next steps:")
fmt.Printf(" 1. %s\n", "Share the "+syncBranch+" branch with your team") fmt.Printf(" 1. %s\n", "Share the "+syncBranch+" branch with your team")
@@ -204,19 +204,19 @@ func createSyncBranch(branchName string) error {
// Branch exists, nothing to do // Branch exists, nothing to do
return nil return nil
} }
// Create new branch from current HEAD // Create new branch from current HEAD
cmd = exec.Command("git", "checkout", "-b", branchName) cmd = exec.Command("git", "checkout", "-b", branchName)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return err return err
} }
// Switch back to original branch // Switch back to original branch
currentBranch, err := getGitBranch() currentBranch, err := getGitBranch()
if err == nil && currentBranch != branchName { if err == nil && currentBranch != branchName {
cmd = exec.Command("git", "checkout", "-") cmd = exec.Command("git", "checkout", "-")
_ = cmd.Run() // Ignore error, branch creation succeeded _ = cmd.Run() // Ignore error, branch creation succeeded
} }
return nil return nil
} }

View File

@@ -12,8 +12,34 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads" "github.com/steveyegge/beads"
"github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
) )
// isDaemonAutoSyncing checks if daemon is running with auto-commit and auto-push enabled.
// Returns false if daemon is not running or check fails (fail-safe to show full protocol).
// This is a variable to allow stubbing in tests.
var isDaemonAutoSyncing = func() bool {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return false
}
socketPath := filepath.Join(beadsDir, "bd.sock")
client, err := rpc.TryConnect(socketPath)
if err != nil || client == nil {
return false
}
defer func() { _ = client.Close() }()
status, err := client.Status()
if err != nil {
return false
}
// Only check auto-commit and auto-push (auto-pull is separate)
return status.AutoCommit && status.AutoPush
}
var ( var (
primeFullMode bool primeFullMode bool
primeMCPMode bool primeMCPMode bool
@@ -181,11 +207,15 @@ func outputPrimeContext(w io.Writer, mcpMode bool, stealthMode bool) error {
func outputMCPContext(w io.Writer, stealthMode bool) error { func outputMCPContext(w io.Writer, stealthMode bool) error {
ephemeral := isEphemeralBranch() ephemeral := isEphemeralBranch()
noPush := config.GetBool("no-push") noPush := config.GetBool("no-push")
autoSync := isDaemonAutoSyncing()
var closeProtocol string var closeProtocol string
if stealthMode { if stealthMode {
// Stealth mode: only flush to JSONL as there's nothing to commit. // Stealth mode: only flush to JSONL as there's nothing to commit.
closeProtocol = "Before saying \"done\": bd sync --flush-only" closeProtocol = "Before saying \"done\": bd sync --flush-only"
} else if autoSync && !ephemeral && !noPush {
// Daemon is auto-syncing - no bd sync needed
closeProtocol = "Before saying \"done\": git status → git add → git commit → git push (beads auto-synced by daemon)"
} else if ephemeral { } else if ephemeral {
closeProtocol = "Before saying \"done\": git status → git add → bd sync --from-main → git commit (no push - ephemeral branch)" closeProtocol = "Before saying \"done\": git status → git add → bd sync --from-main → git commit (no push - ephemeral branch)"
} else if noPush { } else if noPush {
@@ -217,11 +247,13 @@ Start: Check ` + "`ready`" + ` tool for available work.
func outputCLIContext(w io.Writer, stealthMode bool) error { func outputCLIContext(w io.Writer, stealthMode bool) error {
ephemeral := isEphemeralBranch() ephemeral := isEphemeralBranch()
noPush := config.GetBool("no-push") noPush := config.GetBool("no-push")
autoSync := isDaemonAutoSyncing()
var closeProtocol string var closeProtocol string
var closeNote string var closeNote string
var syncSection string var syncSection string
var completingWorkflow string var completingWorkflow string
var gitWorkflowRule string
if stealthMode { if stealthMode {
// Stealth mode: only flush to JSONL, no git operations // Stealth mode: only flush to JSONL, no git operations
@@ -233,6 +265,23 @@ func outputCLIContext(w io.Writer, stealthMode bool) error {
bd close <id1> <id2> ... # Close all completed issues at once bd close <id1> <id2> ... # Close all completed issues at once
bd sync --flush-only # Export to JSONL bd sync --flush-only # Export to JSONL
` + "```" ` + "```"
gitWorkflowRule = "Git workflow: stealth mode (no git ops)"
} else if autoSync && !ephemeral && !noPush {
// Daemon is auto-syncing - simplified protocol (no bd sync needed)
closeProtocol = `[ ] 1. git status (check what changed)
[ ] 2. git add <files> (stage code changes)
[ ] 3. git commit -m "..." (commit code)
[ ] 4. git push (push to remote)`
closeNote = "**Note:** Daemon is auto-syncing beads changes. No manual `bd sync` needed."
syncSection = `### Sync & Collaboration
- Daemon handles beads sync automatically (auto-commit + auto-push + auto-pull enabled)
- ` + "`bd sync --status`" + ` - Check sync status`
completingWorkflow = `**Completing work:**
` + "```bash" + `
bd close <id1> <id2> ... # Close all completed issues at once
git push # Push to remote (beads auto-synced by daemon)
` + "```"
gitWorkflowRule = "Git workflow: daemon auto-syncs beads changes"
} else if ephemeral { } else if ephemeral {
closeProtocol = `[ ] 1. git status (check what changed) closeProtocol = `[ ] 1. git status (check what changed)
[ ] 2. git add <files> (stage code changes) [ ] 2. git add <files> (stage code changes)
@@ -249,6 +298,7 @@ bd sync --from-main # Pull latest beads from main
git add . && git commit -m "..." # Commit your changes git add . && git commit -m "..." # Commit your changes
# Merge to main when ready (local merge, not push) # Merge to main when ready (local merge, not push)
` + "```" ` + "```"
gitWorkflowRule = "Git workflow: run `bd sync --from-main` at session end"
} else if noPush { } else if noPush {
closeProtocol = `[ ] 1. git status (check what changed) closeProtocol = `[ ] 1. git status (check what changed)
[ ] 2. git add <files> (stage code changes) [ ] 2. git add <files> (stage code changes)
@@ -265,6 +315,7 @@ bd close <id1> <id2> ... # Close all completed issues at once
bd sync # Sync beads (push disabled) bd sync # Sync beads (push disabled)
# git push # Run manually when ready # git push # Run manually when ready
` + "```" ` + "```"
gitWorkflowRule = "Git workflow: run `bd sync` at session end (push disabled)"
} else { } else {
closeProtocol = `[ ] 1. git status (check what changed) closeProtocol = `[ ] 1. git status (check what changed)
[ ] 2. git add <files> (stage code changes) [ ] 2. git add <files> (stage code changes)
@@ -281,6 +332,7 @@ bd sync # Sync beads (push disabled)
bd close <id1> <id2> ... # Close all completed issues at once bd close <id1> <id2> ... # Close all completed issues at once
bd sync # Push to remote bd sync # Push to remote
` + "```" ` + "```"
gitWorkflowRule = "Git workflow: hooks auto-sync, run `bd sync` at session end"
} }
redirectNotice := getRedirectNotice(true) redirectNotice := getRedirectNotice(true)
@@ -304,7 +356,7 @@ bd sync # Push to remote
- Track strategic work in beads (multi-session, dependencies, discovered work) - Track strategic work in beads (multi-session, dependencies, discovered work)
- Use ` + "`bd create`" + ` for issues, TodoWrite for simple single-session execution - Use ` + "`bd create`" + ` for issues, TodoWrite for simple single-session execution
- When in doubt, prefer bd—persistence you don't need beats lost context - When in doubt, prefer bd—persistence you don't need beats lost context
- Git workflow: hooks auto-sync, run ` + "`bd sync`" + ` at session end - ` + gitWorkflowRule + `
- Session management: check ` + "`bd ready`" + ` for available work - Session management: check ` + "`bd ready`" + ` for available work
## Essential Commands ## Essential Commands

View File

@@ -68,6 +68,7 @@ func TestOutputContextFunction(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
defer stubIsEphemeralBranch(tt.ephemeralMode)() defer stubIsEphemeralBranch(tt.ephemeralMode)()
defer stubIsDaemonAutoSyncing(false)() // Default: no auto-sync in tests
var buf bytes.Buffer var buf bytes.Buffer
err := outputPrimeContext(&buf, tt.mcpMode, tt.stealthMode) err := outputPrimeContext(&buf, tt.mcpMode, tt.stealthMode)
@@ -108,3 +109,15 @@ func stubIsEphemeralBranch(isEphem bool) func() {
isEphemeralBranch = original isEphemeralBranch = original
} }
} }
// stubIsDaemonAutoSyncing temporarily replaces isDaemonAutoSyncing
// with a stub returning returnValue.
func stubIsDaemonAutoSyncing(isAutoSync bool) func() {
original := isDaemonAutoSyncing
isDaemonAutoSyncing = func() bool {
return isAutoSync
}
return func() {
isDaemonAutoSyncing = original
}
}

View File

@@ -19,15 +19,15 @@ func TestLocalOnlyMode(t *testing.T) {
// Create temp directory for local-only repo // Create temp directory for local-only repo
tempDir := t.TempDir() tempDir := t.TempDir()
// Initialize local git repo without remote // Initialize local git repo without remote
runGitCmd(t, tempDir, "init") runGitCmd(t, tempDir, "init")
runGitCmd(t, tempDir, "config", "user.email", "test@example.com") runGitCmd(t, tempDir, "config", "user.email", "test@example.com")
runGitCmd(t, tempDir, "config", "user.name", "Test User") runGitCmd(t, tempDir, "config", "user.name", "Test User")
// Change to temp directory so git commands run in the test repo // Change to temp directory so git commands run in the test repo
t.Chdir(tempDir) t.Chdir(tempDir)
// Verify no remote exists // Verify no remote exists
cmd := exec.Command("git", "remote") cmd := exec.Command("git", "remote")
output, err := cmd.Output() output, err := cmd.Output()
@@ -39,19 +39,19 @@ func TestLocalOnlyMode(t *testing.T) {
} }
ctx := context.Background() ctx := context.Background()
// Test hasGitRemote returns false // Test hasGitRemote returns false
if hasGitRemote(ctx) { if hasGitRemote(ctx) {
t.Error("Expected hasGitRemote to return false for local-only repo") t.Error("Expected hasGitRemote to return false for local-only repo")
} }
// Test gitPull returns nil (no error) // Test gitPull returns nil (no error)
if err := gitPull(ctx); err != nil { if err := gitPull(ctx, ""); err != nil {
t.Errorf("gitPull should gracefully skip when no remote, got error: %v", err) t.Errorf("gitPull should gracefully skip when no remote, got error: %v", err)
} }
// Test gitPush returns nil (no error) // Test gitPush returns nil (no error)
if err := gitPush(ctx); err != nil { if err := gitPush(ctx, ""); err != nil {
t.Errorf("gitPush should gracefully skip when no remote, got error: %v", err) t.Errorf("gitPush should gracefully skip when no remote, got error: %v", err)
} }
@@ -60,7 +60,7 @@ func TestLocalOnlyMode(t *testing.T) {
if err := os.MkdirAll(beadsDir, 0750); err != nil { if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err) t.Fatalf("Failed to create .beads dir: %v", err)
} }
jsonlPath := filepath.Join(beadsDir, "issues.jsonl") jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0644); err != nil { if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0644); err != nil {
t.Fatalf("Failed to write JSONL: %v", err) t.Fatalf("Failed to write JSONL: %v", err)
@@ -102,7 +102,7 @@ func TestWithRemote(t *testing.T) {
// Clone it // Clone it
runGitCmd(t, tempDir, "clone", remoteDir, cloneDir) runGitCmd(t, tempDir, "clone", remoteDir, cloneDir)
// Change to clone directory // Change to clone directory
t.Chdir(cloneDir) t.Chdir(cloneDir)
@@ -116,5 +116,5 @@ func TestWithRemote(t *testing.T) {
// Verify git pull doesn't error (even with empty remote) // Verify git pull doesn't error (even with empty remote)
// Note: pull might fail with "couldn't find remote ref", but that's different // Note: pull might fail with "couldn't find remote ref", but that's different
// from the fatal "'origin' does not appear to be a git repository" error // from the fatal "'origin' does not appear to be a git repository" error
gitPull(ctx) // Just verify it doesn't panic _ = gitPull(ctx, "") // Just verify it doesn't panic
} }

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -15,12 +16,30 @@ func TestMain(m *testing.M) {
// This ensures backward compatibility with tests that manipulate globals directly. // This ensures backward compatibility with tests that manipulate globals directly.
enableTestModeGlobals() enableTestModeGlobals()
// Prevent daemon auto-start and ensure tests don't interact with any running daemon.
// This prevents false positives in the test guard when a background daemon touches
// .beads files (like issues.jsonl via auto-sync) during test execution.
origNoDaemon := os.Getenv("BEADS_NO_DAEMON")
os.Setenv("BEADS_NO_DAEMON", "1")
defer func() {
if origNoDaemon != "" {
os.Setenv("BEADS_NO_DAEMON", origNoDaemon)
} else {
os.Unsetenv("BEADS_NO_DAEMON")
}
}()
if os.Getenv("BEADS_TEST_GUARD_DISABLE") != "" { if os.Getenv("BEADS_TEST_GUARD_DISABLE") != "" {
os.Exit(m.Run()) os.Exit(m.Run())
} }
// Stop any running daemon for this repo to prevent false positives in the guard.
// The daemon auto-syncs and touches files like issues.jsonl, which would trigger
// the guard even though tests didn't cause the change.
repoRoot := findRepoRoot() repoRoot := findRepoRoot()
if repoRoot == "" { if repoRoot != "" {
stopRepoDaemon(repoRoot)
} else {
os.Exit(m.Run()) os.Exit(m.Run())
} }
@@ -120,3 +139,28 @@ func findRepoRoot() string {
} }
return "" return ""
} }
// stopRepoDaemon stops any running daemon for the given repository.
// This prevents false positives in the test guard when a background daemon
// touches .beads files during test execution. Uses exec to avoid import cycles.
func stopRepoDaemon(repoRoot string) {
beadsDir := filepath.Join(repoRoot, ".beads")
socketPath := filepath.Join(beadsDir, "bd.sock")
// Check if socket exists (quick check before shelling out)
if _, err := os.Stat(socketPath); err != nil {
return // no daemon running
}
// Shell out to bd daemon --stop. We can't call the daemon functions directly
// from TestMain because they have complex dependencies. Using exec is cleaner.
cmd := exec.Command("bd", "daemon", "--stop")
cmd.Dir = repoRoot
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
// Best-effort stop - ignore errors (daemon may not be running)
_ = cmd.Run()
// Give daemon time to shutdown gracefully
time.Sleep(500 * time.Millisecond)
}

749
docs/daemon-summary.md Normal file
View File

@@ -0,0 +1,749 @@
# Beads Daemon: Technical Analysis and Architectural Guide
A comprehensive analysis of the beads daemon implementation, with learnings applicable to other projects implementing similar background process patterns.
---
## Table of Contents
1. [Overview](#overview)
2. [Goals and Purpose](#goals-and-purpose)
3. [Architecture Deep Dive](#architecture-deep-dive)
4. [Memory Analysis](#memory-analysis-why-30-35mb)
5. [Platform Support](#platform-support-comparison)
6. [Historical Problems and Fixes](#historical-problems-and-fixes)
7. [Daemon Without Database Analysis](#daemon-without-database-analysis)
8. [Architectural Guidance for Other Projects](#architectural-guidance-for-other-projects)
9. [Technical Design Patterns](#technical-design-patterns)
10. [Proposed Improvements (Expert-Reviewed)](#proposed-improvements-expert-reviewed)
11. [Configuration Reference](#configuration-reference)
12. [Key Contributors](#key-contributors)
13. [Conclusion](#conclusion)
---
## Overview
The `bd daemon` is a background process that provides automatic synchronization between the local SQLite database and the git-tracked JSONL file. It follows an **LSP-style model** with one daemon per workspace, communicating via Unix domain sockets (or named pipes on Windows).
**Key insight:** The daemon exists primarily to automate a single operation - `bd export` before git commits. Everything else is secondary.
---
## Goals and Purpose
### Primary Goals
| Goal | How Daemon Achieves It | Value |
|------|------------------------|-------|
| **Data safety** | Auto-exports changes to JSONL (500ms debounce) | Users don't lose work if they forget `bd sync` |
| **Multi-agent coordination** | Single point of database access via RPC | Prevents SQLite locking conflicts |
| **Team collaboration** | Auto-commit/push in background | Changes reach remote without manual intervention |
### Secondary Goals
| Goal | How Daemon Achieves It | Value |
|------|------------------------|-------|
| **Performance** | Holds DB connection open, batches operations | Faster subsequent queries |
| **Real-time monitoring** | Enables `bd watch` and status updates | Live feedback on issue state |
| **Version management** | Auto-detects version mismatches | Prevents incompatible daemon/CLI combinations |
### What the Daemon is NOT For
- **Not a system monitor** - Don't add disk space, CPU, or general health monitoring
- **Not a task scheduler** - Don't add cron-like job scheduling
- **Not a server** - It's a local process, not meant for remote access
---
## Architecture Deep Dive
### Component Diagram
```mermaid
flowchart TB
subgraph daemon["BD DAEMON PROCESS"]
rpc["RPC Server<br/>(bd.sock)"]
mutation["Mutation Channel<br/>(512 buffer)"]
debounce["Debouncer<br/>(500ms)"]
sqlite["SQLite Store"]
sync["Sync Engine<br/>(export/import)"]
watcher["File Watcher<br/>(fsnotify)"]
end
rpc --> mutation
mutation --> debounce
debounce --> sync
rpc --> sqlite
sqlite --> sync
sync --> jsonl["issues.jsonl"]
sync --> git["git commit/push"]
watcher --> jsonl
```
### Key Files
| Component | File Path | Purpose |
|-----------|-----------|---------|
| CLI entry | `cmd/bd/daemon.go` | Command definition, flag handling |
| Lifecycle | `cmd/bd/daemon_lifecycle.go` | Startup, shutdown, graceful termination |
| Event loop | `cmd/bd/daemon_event_loop.go` | Main loop, ticker coordination |
| File watcher | `cmd/bd/daemon_watcher.go` | fsnotify integration |
| Debouncer | `cmd/bd/daemon_debouncer.go` | Event batching |
| Sync engine | `cmd/bd/daemon_sync.go` | Export, import, git operations |
| Auto-start | `cmd/bd/daemon_autostart.go` | Version checks, restart logic |
| RPC server | `internal/rpc/server_core.go` | Connection handling, protocol |
| Unix impl | `cmd/bd/daemon_unix.go` | Signal handling, flock |
| Windows impl | `cmd/bd/daemon_windows.go` | Named pipes, process management |
### Communication Protocol
```mermaid
sequenceDiagram
participant CLI as CLI Command
participant D as Daemon
CLI->>D: Unix Socket Connect
CLI->>D: JSON-RPC Request<br/>{"method": "create", "params": {...}}
D->>CLI: JSON-RPC Response<br/>{"result": {...}}
D->>CLI: Connection Close
```
### Event-Driven vs Polling Mode
| Mode | Trigger | Latency | CPU Usage | Default Since |
|------|---------|---------|-----------|---------------|
| **Events** | fsnotify + RPC mutations | <500ms | ~0.5% idle | v0.21.0 |
| **Polling** | 5-second ticker | ~5000ms | ~2-3% continuous | v0.1.0 |
**Event mode flow:**
1. RPC mutation received -> pushed to mutation channel
2. Mutation listener picks up event -> triggers debouncer
3. After 500ms quiet period -> export to JSONL
4. If auto-commit enabled -> git commit
5. If auto-push enabled -> git push
---
## Memory Analysis: Why 30-35MB?
### Memory Breakdown
| Component | Memory | Notes |
|-----------|--------|-------|
| **SQLite connection pool** | 12-20 MB | `NumCPU + 1` connections, WASM runtime |
| **WASM runtime (wazero)** | 5-10 MB | JIT-compiled SQLite, cached on disk |
| **Go runtime** | 5-8 MB | GC, scheduler, runtime structures |
| **RPC buffers** | 0.4-12.8 MB | 128KB per active connection |
| **Mutation channel** | ~200 KB | 512-event buffer, 300-400 bytes each |
| **File watcher** | ~10 KB | 4 watched paths |
| **Goroutine stacks** | ~220 KB | ~110 goroutines x 2KB each |
### Why SQLite Uses So Much Memory
The beads daemon uses `ncruces/go-sqlite3`, which embeds SQLite as **WebAssembly** via the wazero runtime. This has tradeoffs:
**Pros:**
- No CGO required - pure Go, cross-compiles easily
- Works on all platforms including WASM
- First startup compiles to native code (~220ms), then cached (~20ms subsequent)
**Cons:**
- Higher baseline memory than CGO-based sqlite drivers
- Per-connection overhead includes WASM instance state
- Connection pool multiplies the overhead
### Memory by Connection Count
| Connections | Expected Memory | Use Case |
|-------------|-----------------|----------|
| 1 (idle) | ~15-20 MB | Local single-user |
| 3 (typical) | ~20-25 MB | Agent workflows |
| 10 | ~25-30 MB | Multi-agent parallel |
| 100 (max) | ~40-50 MB | Stress testing |
### Is 30-35MB Reasonable?
**Yes.** For context:
- VS Code language servers: 50-200+ MB each
- Node.js process: ~30-50 MB baseline
- Typical Go HTTP server: ~20-40 MB
- Docker daemon: ~50-100 MB
The beads daemon is **efficient for what it does**. The memory is dominated by SQLite, which provides actual value (query performance, connection pooling).
### Memory Optimization Options
If memory is a concern:
```bash
# Reduce max connections (default: 100)
export BEADS_DAEMON_MAX_CONNS=10
# Use direct mode (no daemon)
export BEADS_NO_DAEMON=true
```
---
## Platform Support Comparison
| Platform | Status | Implementation | Known Issues |
|----------|--------|----------------|--------------|
| **macOS** | Full support | FSEvents via kqueue | Path casing mismatches (fixed GH#880) |
| **Linux** | Full support | inotify | Resource limits (`ulimit -n`) |
| **Windows** | Partial | Named pipes, `ReadDirectoryChangesW` | MCP server falls back to direct mode |
| **WSL** | Limited | Reduced fsnotify reliability | Recommend polling mode |
| **WASM** | None | No-op stubs | Daemon operations not supported |
### Windows Limitations
Windows has more limited daemon support due to:
1. **No Unix domain sockets** - Uses named pipes instead, which have different semantics
2. **Python asyncio incompatibility** - MCP server cannot use `asyncio.open_unix_connection()`
3. **Graceful fallback** - Windows automatically uses direct CLI mode (GH#387)
### Platform-Specific Implementation Files
```
cmd/bd/daemon_unix.go # Linux, macOS, BSD - signals, flock
cmd/bd/daemon_windows.go # Windows - named pipes, process groups
cmd/bd/daemon_wasm.go # WASM - no-op stubs
```
---
## Historical Problems and Fixes
### Critical Issues Resolved
| Issue | Root Cause | Fix | Commit |
|-------|------------|-----|--------|
| **Startup timeout >5s** (GH#863) | Legacy database without repo fingerprint validation | Propagate actual error to user instead of generic timeout | `2f96795f` |
| **Path casing mismatch** (GH#880) | macOS case-insensitive paths vs case-sensitive string comparison | Use `utils.PathsEqual()` for all path comparisons | `b789b995`, `7b90678a`, `a1079fcb` |
| **Stale daemon.lock delays** | PID reuse after crash - waiting for socket that never appears | Use flock-based liveness check instead of PID | `21de4353` |
| **Event storms** (GH#883) | Empty `gitRefsPath` triggering infinite loops | Guard against empty paths before watching | `b3d64d47` |
| **Commands failing in daemon mode** (GH#719, GH#751) | Commands accessing nil store directly | Add direct-store fallback pattern | `bd6fa5cb`, `78b81341` |
| **Sync branch divergence** (GH#697) | Push failures on diverged branches | Fetch-rebase-retry pattern | `ee016bbb` |
| **Missing tombstones** (GH#696) | Deletions not propagating to other clones | Include tombstones in export | `82cbd98e` |
### Learnings from Each Bug Class
#### 1. Dual-Mode Command Support
**Problem:** Commands like `bd graph`, `bd create -f` worked in direct mode but crashed in daemon mode.
**Root cause:** Code accessed `store` global variable, which is `nil` when daemon is running.
**Pattern fix:**
```go
// BAD - crashes in daemon mode
func myCommand() {
result := store.Query(...) // store is nil
}
// GOOD - works in both modes
func myCommand() {
if store == nil {
// Initialize direct store as fallback
store, _ = sqlite.Open(dbPath)
defer store.Close()
}
result := store.Query(...)
}
```
**Prevention:** Every new command must be tested in both daemon and direct mode.
#### 2. Path Normalization on Case-Insensitive Filesystems
**Problem:** macOS treats `/Users/Bob/MyProject` and `/users/bob/myproject` as the same path, but Go's `==` doesn't.
**Pattern fix:**
```go
// BAD - fails on macOS
if path1 == path2 { ... }
// GOOD - handles case-insensitive filesystems
if utils.PathsEqual(path1, path2) { ... }
```
**Implementation of PathsEqual:**
```go
func PathsEqual(a, b string) bool {
if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
return strings.EqualFold(filepath.Clean(a), filepath.Clean(b))
}
return filepath.Clean(a) == filepath.Clean(b)
}
```
#### 3. Process Liveness Detection
**Problem:** Checking if PID exists doesn't work reliably - PIDs get reused after crashes.
**Pattern fix:**
```go
// BAD - PIDs get reused
if processExists(pid) { /* daemon is alive */ }
// GOOD - file locks released on process death
if isLockHeld(lockFile) { /* daemon is alive */ }
```
**Key insight:** The operating system releases file locks when a process dies, even if it crashes. This is immune to PID reuse.
#### 4. Debouncing File Events
**Problem:** File watcher fires multiple events for single operation, causing event storms.
**Pattern fix:**
```go
type Debouncer struct {
timer *time.Timer
duration time.Duration
action func()
mu sync.Mutex
}
func (d *Debouncer) Trigger() {
d.mu.Lock()
defer d.mu.Unlock()
if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.duration, d.action)
}
```
**Beads uses:** 500ms debounce window, which batches rapid file changes into single sync operations.
---
## Daemon Without Database Analysis
### The Question
> Is a daemon useful WITHOUT a database, just for managing async tasks and keeping the main CLI event loop free?
### Analysis
The beads daemon's primary value is **auto-sync to JSONL** (avoiding manual `bd export` before commits). Without a database, this use case disappears.
| Use Case | Value Without DB | Reasoning |
|----------|------------------|-----------|
| File watching & debouncing | **Low** | CLI is source of truth, not files on disk |
| Background git operations | **Low** | Git commands are fast (milliseconds); async adds complexity without UX benefit |
| Health monitoring | **Low** | What would you monitor? This is scope creep |
| RPC coordination | **Low** | Beads is single-user by design; no shared state needed |
### When to Use a Daemon (General Guidance)
**Use a daemon when you have:**
- Long-running background work (indexing, compilation, sync)
- Shared state across processes (LSP servers, database connections)
- Event-driven updates that need immediate response
- Resource pooling that amortizes expensive operations
**Avoid daemons when:**
- Commands complete in milliseconds
- No shared state between invocations
- Synchronous feedback is expected
- Complexity exceeds benefit
### Verdict for Beads
**The daemon should ONLY exist when using SQLite storage for auto-sync.**
Without a database:
- No sync needed (git is the only source)
- No long-running queries to optimize
- No connection pooling benefits
- No async operations that improve UX
**Recommendation:** Document that daemon mode requires SQLite and serves ONLY to automate `bd export`. Don't expand daemon scope beyond this clear purpose.
---
## Architectural Guidance for Other Projects
If you're implementing a similar daemon pattern, here are the key learnings from beads:
### 1. Design Principles
| Principle | Description |
|-----------|-------------|
| **Single Responsibility** | One daemon, one job (beads: auto-sync). Resist scope creep. |
| **Graceful Degradation** | Always have a direct mode fallback. CLI should work without daemon. |
| **Transparent Operation** | Daemon should be invisible when working. Only surface errors when user needs to act. |
| **Robust Lifecycle** | Use file locks, not PIDs, for liveness. Handle crashes, version mismatches, stale state. |
| **Platform Awareness** | Abstract platform differences early. Test on all target platforms. |
### 2. Essential Components
Every daemon needs these components:
| Component | Purpose | Beads Implementation |
|-----------|---------|----------------------|
| **IPC mechanism** | CLI-to-daemon communication | Unix sockets, named pipes |
| **Liveness detection** | Is daemon running? | File locks (not PIDs) |
| **Version checking** | Prevent mismatches | Compare versions on connect |
| **Graceful shutdown** | Clean termination | Signal handlers + timeout |
| **Auto-restart** | Recovery from crashes | On connect, check version |
| **Logging** | Debugging | Structured logs with rotation |
| **Health checks** | Self-diagnosis | Periodic integrity checks |
### 3. IPC Protocol Design
**Keep it simple:**
```json
// Request
{"method": "create", "params": {"title": "Bug fix"}, "id": 1}
// Response
{"result": {"id": "bd-abc123"}, "id": 1}
// Error
{"error": {"code": -1, "message": "Not found"}, "id": 1}
```
**Recommendations:**
- Use JSON-RPC or similar simple protocol
- Include request IDs for correlation
- Keep messages small (don't stream large data)
- Set reasonable timeouts (beads: 30s default)
### 4. File Lock Pattern
```go
// Recommended: flock-based liveness check
func isDaemonAlive(lockPath string) bool {
f, err := os.OpenFile(lockPath, os.O_RDONLY, 0644)
if err != nil {
return false // Lock file doesn't exist
}
defer f.Close()
// Try to acquire exclusive lock (non-blocking)
err = syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
if err != nil {
return true // Lock held by daemon - it's alive
}
// We got the lock, meaning daemon is dead
syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
return false
}
```
### 5. Event Debouncing Pattern
```go
// Generic debouncer - prevents event storms
type Debouncer struct {
mu sync.Mutex
timer *time.Timer
duration time.Duration
callback func()
}
func NewDebouncer(d time.Duration, cb func()) *Debouncer {
return &Debouncer{duration: d, callback: cb}
}
func (d *Debouncer) Trigger() {
d.mu.Lock()
defer d.mu.Unlock()
if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.duration, func() {
d.mu.Lock()
d.timer = nil
d.mu.Unlock()
d.callback()
})
}
```
### 6. Dual-Mode Command Pattern
```go
// Ensure all commands work in both daemon and direct mode
func runCommand(ctx context.Context) error {
// Try daemon first
if daemonClient := tryConnectDaemon(); daemonClient != nil {
return runViaDaemon(ctx, daemonClient)
}
// Fall back to direct mode
store, err := openDatabase()
if err != nil {
return err
}
defer store.Close()
return runDirect(ctx, store)
}
```
### 7. Platform Abstraction
```go
// daemon_unix.go
//go:build unix
func acquireLock(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return nil, err
}
err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
if err != nil {
f.Close()
return nil, err
}
return f, nil
}
// daemon_windows.go
//go:build windows
func acquireLock(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return nil, err
}
err = windows.LockFileEx(
windows.Handle(f.Fd()),
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
0, 1, 0, &windows.Overlapped{},
)
if err != nil {
f.Close()
return nil, err
}
return f, nil
}
```
---
## Technical Design Patterns
### Pattern 1: Mutation Channel with Dropped Event Detection
```go
type Server struct {
mutationChan chan MutationEvent
mutationCounter uint64
droppedCounter uint64
}
func (s *Server) SendMutation(event MutationEvent) {
atomic.AddUint64(&s.mutationCounter, 1)
select {
case s.mutationChan <- event:
// Sent successfully
default:
// Channel full - record dropped event
atomic.AddUint64(&s.droppedCounter, 1)
}
}
// Event loop periodically checks for drops
func (s *Server) checkDroppedEvents() {
if atomic.LoadUint64(&s.droppedCounter) > 0 {
// Trigger full sync to recover
s.triggerFullSync()
atomic.StoreUint64(&s.droppedCounter, 0)
}
}
```
### Pattern 2: Exponential Backoff for Sync Failures
```go
type SyncState struct {
Failures int `json:"failures"`
LastFailure time.Time `json:"last_failure"`
NextRetry time.Time `json:"next_retry"`
}
func (s *SyncState) GetBackoffDuration() time.Duration {
backoffs := []time.Duration{
30 * time.Second,
1 * time.Minute,
2 * time.Minute,
5 * time.Minute,
10 * time.Minute,
30 * time.Minute, // Max
}
idx := s.Failures
if idx >= len(backoffs) {
idx = len(backoffs) - 1
}
return backoffs[idx]
}
```
### Pattern 3: Global Registry for Multi-Daemon Discovery
```go
// ~/.beads/registry.json
type Registry struct {
Daemons []DaemonEntry `json:"daemons"`
}
type DaemonEntry struct {
Workspace string `json:"workspace"`
SocketPath string `json:"socket_path"`
PID int `json:"pid"`
Version string `json:"version"`
StartTime time.Time `json:"start_time"`
}
// O(1) daemon lookup instead of filesystem scan
func FindDaemon(workspace string) (*DaemonEntry, error) {
registry := loadRegistry()
for _, d := range registry.Daemons {
if d.Workspace == workspace {
return &d, nil
}
}
return nil, ErrNotFound
}
```
---
## Proposed Improvements (Expert-Reviewed)
Each improvement has been reviewed for actual value vs complexity.
### Recommended (High Value, Low-Medium Complexity)
| Improvement | Value | Complexity | Reasoning |
|-------------|-------|------------|-----------|
| **CI matrix tests (Windows/macOS/Linux)** | High | Low | GitHub Actions makes this trivial; catches platform bugs early |
| **Automated daemon vs direct mode tests** | High | Medium | Prevents regressions; ensures feature parity |
| **`bd doctor daemon` subcommand** | Medium | Low | High ROI for debugging; user self-service |
### Deferred (Wait for User Demand)
| Improvement | Value | Complexity | Reasoning |
|-------------|-------|------------|-----------|
| **Per-worktree daemon instances** | Medium | High | Useful for monorepos but adds significant complexity; wait for demand |
| **Document ulimit settings** | Low | Low | Only relevant if users hit limits; document when reported |
### Skip (Over-Engineered)
| Improvement | Value | Complexity | Reasoning |
|-------------|-------|------------|-----------|
| **Windows named pipe support in MCP** | Low | Medium | MCP server (bd-5) isn't implemented yet; premature optimization |
| **Auto-detect NFS/SMB mounts** | Low | High | Over-engineered for rare edge case; let users opt into polling |
| **Prometheus metrics endpoint** | Low | Medium | Overkill for local dev tool; who monitors their local issue tracker? |
### Priority Order for Implementation
1. **CI matrix tests** - Prevents regressions, low effort
2. **Daemon vs direct mode tests** - Ensures correctness
3. **`bd doctor daemon`** - Improves debuggability
**Everything else:** Skip or defer until proven user demand.
---
## Configuration Reference
### config.yaml
```yaml
# .beads/config.yaml
daemon:
auto-sync: true # Enable all auto-* settings (recommended)
auto-commit: true # Commit changes automatically
auto-push: true # Push to remote automatically
auto-pull: true # Pull from remote periodically
remote-sync-interval: "30s" # How often to pull remote updates
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BEADS_NO_DAEMON` | `false` | Disable daemon entirely |
| `BEADS_AUTO_START_DAEMON` | `true` | Auto-start on first command |
| `BEADS_DAEMON_MODE` | `events` | `events` (instant) or `poll` (5s) |
| `BEADS_REMOTE_SYNC_INTERVAL` | `30s` | How often to pull from remote |
| `BEADS_DAEMON_MAX_CONNS` | `100` | Max concurrent RPC connections |
| `BEADS_MUTATION_BUFFER` | `512` | Mutation channel buffer size |
| `BEADS_WATCHER_FALLBACK` | `true` | Fall back to polling if fsnotify fails |
### Disabling the Daemon
```bash
# Single command
bd --no-daemon list
# Entire session
export BEADS_NO_DAEMON=true
# CI/CD pipelines
# Add to your CI config
env:
BEADS_NO_DAEMON: "true"
```
---
## Key Contributors
Based on git history analysis:
| Contributor | Daemon File Commits | Role |
|-------------|---------------------|------|
| **Steve Yegge** | 198 | Primary author and maintainer |
| **Charles P. Cross** | 16 | Significant contributor |
| **Ryan Snodgrass** | 10 | Auto-sync config improvements |
| **Jordan Hubbard** | 6 | Platform support |
**Steve Yegge** is the primary expert on the daemon, having authored the majority of daemon code and fixed most critical issues.
---
## Conclusion
### What Works Well
1. **Event-driven mode** - <500ms latency with ~60% less CPU than polling
2. **Graceful degradation** - CLI works seamlessly without daemon
3. **Memory efficiency** - 30-35MB is reasonable for SQLite + WASM runtime
4. **Robust lifecycle** - File locks, version checking, auto-restart
5. **Cross-platform** - Works on macOS, Linux; degrades gracefully on Windows
### Remaining Gaps
1. **Windows** - Full daemon functionality not available via MCP
2. **Git worktrees** - Requires manual configuration or `--no-daemon`
3. **Testing coverage** - Need systematic daemon-mode testing for all commands
### Key Takeaways for Other Projects
1. **Keep daemon scope narrow** - One daemon, one job
2. **Always have a direct mode fallback** - Users shouldn't depend on daemon
3. **Use file locks for liveness** - PIDs get reused after crashes
4. **Debounce file events** - 500ms is a good starting point
5. **Test both modes** - Every command must work with and without daemon
6. **Handle path case-sensitivity** - macOS and Windows are case-insensitive
7. **30-50MB memory is normal** - Don't over-optimize unless users complain
---
*Generated from analysis of beads codebase, git history (100+ daemon-related commits), GitHub issues (#387, #696, #697, #719, #751, #863, #880, #883, #890), and architectural review.*

View File

@@ -0,0 +1,97 @@
package sqlite
import (
"context"
"testing"
"time"
"github.com/steveyegge/beads/internal/types"
)
// TestGateFieldsPreservedAcrossConnections reproduces beads-70c4:
// Gate await fields should not be cleared when a new database connection
// is opened (simulating --no-daemon CLI access).
func TestGateFieldsPreservedAcrossConnections(t *testing.T) {
// Use a temporary file database (not :memory:) to simulate real-world scenario
dbPath := t.TempDir() + "/beads.db"
ctx := context.Background()
// Step 1: Create a database and add a gate with await fields
// (simulating daemon creating a gate)
store1, err := New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create first store: %v", err)
}
// Initialize the database with a prefix
if err := store1.SetConfig(ctx, "issue_prefix", "beads"); err != nil {
t.Fatalf("failed to set issue_prefix: %v", err)
}
gate := &types.Issue{
ID: "beads-test1",
Title: "Test Gate",
Status: types.StatusOpen,
Priority: 1,
IssueType: types.TypeGate,
Ephemeral: true,
AwaitType: "timer",
AwaitID: "5s",
Timeout: 5 * time.Second,
Waiters: []string{"system"},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
gate.ContentHash = gate.ComputeContentHash()
if err := store1.CreateIssue(ctx, gate, "daemon"); err != nil {
t.Fatalf("failed to create gate: %v", err)
}
// Verify gate was created with await fields
retrieved1, err := store1.GetIssue(ctx, gate.ID)
if err != nil || retrieved1 == nil {
t.Fatalf("failed to get gate from store1: %v", err)
}
if retrieved1.AwaitType != "timer" {
t.Errorf("store1: expected AwaitType=timer, got %q", retrieved1.AwaitType)
}
if retrieved1.AwaitID != "5s" {
t.Errorf("store1: expected AwaitID=5s, got %q", retrieved1.AwaitID)
}
// Close the first store (simulating daemon connection)
if err := store1.Close(); err != nil {
t.Fatalf("failed to close store1: %v", err)
}
// Step 2: Open a NEW connection to the same database
// (simulating `bd show --no-daemon` opening a new connection)
store2, err := New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create second store: %v", err)
}
defer store2.Close()
// Step 3: Read the gate from the new connection
// This should NOT clear the await fields
retrieved2, err := store2.GetIssue(ctx, gate.ID)
if err != nil || retrieved2 == nil {
t.Fatalf("failed to get gate from store2: %v", err)
}
// Verify await fields are PRESERVED
if retrieved2.AwaitType != "timer" {
t.Errorf("AwaitType was cleared! expected 'timer', got %q", retrieved2.AwaitType)
}
if retrieved2.AwaitID != "5s" {
t.Errorf("AwaitID was cleared! expected '5s', got %q", retrieved2.AwaitID)
}
if retrieved2.Timeout != 5*time.Second {
t.Errorf("Timeout was cleared! expected %v, got %v", 5*time.Second, retrieved2.Timeout)
}
if len(retrieved2.Waiters) != 1 || retrieved2.Waiters[0] != "system" {
t.Errorf("Waiters was cleared! expected [system], got %v", retrieved2.Waiters)
}
}