From a4eb4fe451b25cd997154045427c1ff9322486a2 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 28 Oct 2025 11:34:27 -0700 Subject: [PATCH] Add two-clone collision test proving beads multi-agent workflow failure - Creates TestTwoCloneCollision integration test - Sets up 2 independent clones with git hooks and daemons - Both file issues with same ID (test-1) - Demonstrates databases don't converge after collision resolution - Clone A: test-1='Issue from clone A', test-2='Issue from clone B' - Clone B: test-1='Issue from clone B', test-2='Issue from clone A' - Git status shows dirty state in both clones - Test proves beads fails at basic multi-agent workflow Also adds --json flag to create, ready, and list commands for better test integration. Amp-Thread-ID: https://ampcode.com/threads/T-8fa0ab6c-2226-4f9b-8e11-14e1156537fc Co-authored-by: Amp --- beads_twoclone_test.go | 279 +++++++++++++++++++++++++++++++++++++++++ cmd/bd/create.go | 2 + cmd/bd/list.go | 2 + cmd/bd/ready.go | 2 + 4 files changed, 285 insertions(+) create mode 100644 beads_twoclone_test.go diff --git a/beads_twoclone_test.go b/beads_twoclone_test.go new file mode 100644 index 00000000..5c999e2b --- /dev/null +++ b/beads_twoclone_test.go @@ -0,0 +1,279 @@ +package beads_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestTwoCloneCollision demonstrates that beads does NOT work with the basic workflow +// of two independent clones filing issues simultaneously. +func TestTwoCloneCollision(t *testing.T) { + tmpDir := t.TempDir() + + // Get path to bd binary + bdPath, err := filepath.Abs("./bd") + if err != nil { + t.Fatalf("Failed to get bd path: %v", err) + } + if _, err := os.Stat(bdPath); err != nil { + t.Fatalf("bd binary not found at %s - run 'go build -o bd ./cmd/bd' first", bdPath) + } + + // Create a bare git repo to act as the remote + remoteDir := filepath.Join(tmpDir, "remote.git") + runCmd(t, tmpDir, "git", "init", "--bare", remoteDir) + + // Create clone A + cloneA := filepath.Join(tmpDir, "clone-a") + runCmd(t, tmpDir, "git", "clone", remoteDir, cloneA) + + // Create clone B + cloneB := filepath.Join(tmpDir, "clone-b") + runCmd(t, tmpDir, "git", "clone", remoteDir, cloneB) + + // Copy bd binary to both clones + copyFile(t, bdPath, filepath.Join(cloneA, "bd")) + copyFile(t, bdPath, filepath.Join(cloneB, "bd")) + + // Initialize beads in clone A + t.Log("Initializing beads in clone A") + runCmd(t, cloneA, "./bd", "init", "--quiet", "--prefix", "test") + + // Commit the initial .beads directory from clone A + runCmd(t, cloneA, "git", "add", ".beads") + runCmd(t, cloneA, "git", "commit", "-m", "Initialize beads") + runCmd(t, cloneA, "git", "push", "origin", "master") + + // Pull in clone B to get the beads initialization + t.Log("Pulling beads init to clone B") + runCmd(t, cloneB, "git", "pull", "origin", "master") + + // Initialize database in clone B from JSONL + t.Log("Initializing database in clone B") + runCmd(t, cloneB, "./bd", "init", "--quiet", "--prefix", "test") + + // Install git hooks in both clones + t.Log("Installing git hooks") + installGitHooks(t, cloneA) + installGitHooks(t, cloneB) + + // Start daemons in both clones with auto-commit and auto-push + t.Log("Starting daemons") + startDaemon(t, cloneA) + startDaemon(t, cloneB) + + // Ensure cleanup happens even if test fails + t.Cleanup(func() { + t.Log("Cleaning up daemons") + stopAllDaemons(t, cloneA) + stopAllDaemons(t, cloneB) + }) + + // Give daemons time to start + time.Sleep(2 * time.Second) + + // Clone A creates an issue + t.Log("Clone A creating issue") + runCmd(t, cloneA, "./bd", "create", "Issue from clone A", "-t", "task", "-p", "1", "--json") + + // Clone B creates an issue (should get same ID since databases are independent) + t.Log("Clone B creating issue") + runCmd(t, cloneB, "./bd", "create", "Issue from clone B", "-t", "task", "-p", "1", "--json") + + // Force sync clone A first + t.Log("Clone A syncing") + runCmd(t, cloneA, "./bd", "sync") + + //Give time for push + time.Sleep(2 * time.Second) + + // Clone B will conflict when syncing + t.Log("Clone B syncing (will conflict)") + syncBOut := runCmdOutputAllowError(t, cloneB, "./bd", "sync") + if !strings.Contains(syncBOut, "CONFLICT") && !strings.Contains(syncBOut, "Error") { + t.Log("Expected conflict during clone B sync, but got success. Output:") + t.Log(syncBOut) + } + + // Clone B needs to abort the rebase and resolve manually + t.Log("Clone B aborting rebase") + runCmdAllowError(t, cloneB, "git", "rebase", "--abort") + + // Pull with merge instead + t.Log("Clone B pulling with merge") + pullOut := runCmdOutputAllowError(t, cloneB, "git", "pull", "--no-rebase", "origin", "master") + if !strings.Contains(pullOut, "CONFLICT") { + t.Logf("Pull output: %s", pullOut) + } + + // Check if we have conflict markers in the JSONL + jsonlPath := filepath.Join(cloneB, ".beads", "issues.jsonl") + jsonlContent, _ := os.ReadFile(jsonlPath) + if strings.Contains(string(jsonlContent), "<<<<<<<") { + t.Log("JSONL has conflict markers - manually resolving") + // For this test, just take both issues (keep all non-marker lines) + var cleanLines []string + for _, line := range strings.Split(string(jsonlContent), "\n") { + if !strings.HasPrefix(line, "<<<<<<<") && + !strings.HasPrefix(line, "=======") && + !strings.HasPrefix(line, ">>>>>>>") { + if strings.TrimSpace(line) != "" { + cleanLines = append(cleanLines, line) + } + } + } + cleaned := strings.Join(cleanLines, "\n") + "\n" + if err := os.WriteFile(jsonlPath, []byte(cleaned), 0644); err != nil { + t.Fatalf("Failed to write cleaned JSONL: %v", err) + } + // Mark as resolved + runCmd(t, cloneB, "git", "add", ".beads/issues.jsonl") + runCmd(t, cloneB, "git", "commit", "-m", "Resolve merge conflict") + } + + // Force import with collision resolution in both + t.Log("Resolving collisions via import") + runCmd(t, cloneB, "./bd", "import", "-i", ".beads/issues.jsonl", "--resolve-collisions") + + // Push the resolved state from clone B + t.Log("Clone B pushing resolved state") + runCmd(t, cloneB, "git", "push", "origin", "master") + + // Clone A now tries to sync - will this work? + t.Log("Clone A syncing after clone B resolved collision") + syncAOut := runCmdOutputAllowError(t, cloneA, "./bd", "sync") + + // Check if clone A also hit a conflict + hasConflict := strings.Contains(syncAOut, "CONFLICT") || strings.Contains(syncAOut, "Error pulling") + + if hasConflict { + t.Log("✓ TEST PROVES THE PROBLEM: Clone A also hit a conflict when syncing!") + t.Log("This demonstrates that the basic two-clone workflow does NOT converge cleanly.") + t.Errorf("EXPECTED FAILURE: beads cannot handle two clones filing issues simultaneously") + return + } + + // If we somehow got here, check if things converged + t.Log("Checking if git status is clean") + statusA := runCmdOutputAllowError(t, cloneA, "git", "status", "--porcelain") + statusB := runCmdOutputAllowError(t, cloneB, "git", "status", "--porcelain") + + if strings.TrimSpace(statusA) != "" { + t.Errorf("Clone A git status not clean:\n%s", statusA) + } + if strings.TrimSpace(statusB) != "" { + t.Errorf("Clone B git status not clean:\n%s", statusB) + } + + // Check if bd ready matches + readyA := runCmdOutputAllowError(t, cloneA, "./bd", "ready", "--json") + readyB := runCmdOutputAllowError(t, cloneB, "./bd", "ready", "--json") + + if readyA != readyB { + t.Log("✓ TEST PROVES THE PROBLEM: Databases did not converge!") + t.Log("Even without conflicts, the two clones have different issue databases.") + t.Errorf("bd ready output differs:\nClone A:\n%s\n\nClone B:\n%s", readyA, readyB) + } else { + t.Log("Unexpected success: beads handled two-clone collision properly!") + } +} + +func installGitHooks(t *testing.T, repoDir string) { + hooksDir := filepath.Join(repoDir, ".git", "hooks") + + preCommit := `#!/bin/sh +./bd --no-daemon export -o .beads/issues.jsonl >/dev/null 2>&1 || true +git add .beads/issues.jsonl >/dev/null 2>&1 || true +exit 0 +` + + postMerge := `#!/bin/sh +./bd --no-daemon import -i .beads/issues.jsonl >/dev/null 2>&1 || true +exit 0 +` + + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(preCommit), 0755); err != nil { + t.Fatalf("Failed to write pre-commit hook: %v", err) + } + + if err := os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(postMerge), 0755); err != nil { + t.Fatalf("Failed to write post-merge hook: %v", err) + } +} + +func startDaemon(t *testing.T, repoDir string) { + cmd := exec.Command("./bd", "daemon", "start", "--auto-commit", "--auto-push") + cmd.Dir = repoDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Logf("Warning: daemon start failed (may already be running): %v", err) + } +} + +func stopAllDaemons(t *testing.T, repoDir string) { + t.Helper() + cmd := exec.Command("./bd", "daemons", "killall", "--force") + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Warning: daemon killall failed (may not be running): %v\nOutput: %s", err, string(out)) + } + // Give daemons time to shut down + time.Sleep(1 * time.Second) +} + +func runCmd(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + t.Fatalf("Command failed: %s %v\nError: %v", name, args, err) + } +} + +func runCmdOutput(t *testing.T, dir string, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Logf("Command output: %s", string(out)) + t.Fatalf("Command failed: %s %v\nError: %v", name, args, err) + } + return string(out) +} + +func copyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatalf("Failed to read %s: %v", src, err) + } + if err := os.WriteFile(dst, data, 0755); err != nil { + t.Fatalf("Failed to write %s: %v", dst, err) + } +} + +func runCmdAllowError(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} + +func runCmdOutputAllowError(t *testing.T, dir string, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, _ := cmd.CombinedOutput() + return string(out) +} diff --git a/cmd/bd/create.go b/cmd/bd/create.go index 582d6c16..8f25ae45 100644 --- a/cmd/bd/create.go +++ b/cmd/bd/create.go @@ -63,6 +63,7 @@ var createCmd = &cobra.Command{ externalRef, _ := cmd.Flags().GetString("external-ref") deps, _ := cmd.Flags().GetStringSlice("deps") forceCreate, _ := cmd.Flags().GetBool("force") + jsonOutput, _ := cmd.Flags().GetBool("json") // Validate explicit ID format if provided (prefix-number) if explicitID != "" { @@ -245,5 +246,6 @@ func init() { createCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')") createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix") + createCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(createCmd) } diff --git a/cmd/bd/list.go b/cmd/bd/list.go index f8cb556a..aa42174e 100644 --- a/cmd/bd/list.go +++ b/cmd/bd/list.go @@ -46,6 +46,7 @@ var listCmd = &cobra.Command{ labelsAny, _ := cmd.Flags().GetStringSlice("label-any") titleSearch, _ := cmd.Flags().GetString("title") idFilter, _ := cmd.Flags().GetString("id") + jsonOutput, _ := cmd.Flags().GetBool("json") // Normalize labels: trim, dedupe, remove empty labels = normalizeLabels(labels) @@ -211,6 +212,7 @@ func init() { listCmd.Flags().IntP("limit", "n", 0, "Limit results") listCmd.Flags().String("format", "", "Output format: 'digraph' (for golang.org/x/tools/cmd/digraph), 'dot' (Graphviz), or Go template") listCmd.Flags().Bool("all", false, "Show all issues (default behavior; flag provided for CLI familiarity)") + listCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(listCmd) } diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index ac979a18..2a419053 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -20,6 +20,7 @@ var readyCmd = &cobra.Command{ limit, _ := cmd.Flags().GetInt("limit") assignee, _ := cmd.Flags().GetString("assignee") sortPolicy, _ := cmd.Flags().GetString("sort") + jsonOutput, _ := cmd.Flags().GetBool("json") filter := types.WorkFilter{ // Leave Status empty to get both 'open' and 'in_progress' (bd-165) @@ -293,6 +294,7 @@ func init() { readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority") readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest") + readyCmd.Flags().Bool("json", false, "Output JSON format") rootCmd.AddCommand(readyCmd) rootCmd.AddCommand(blockedCmd)