tests: add chaos doctor repair coverage and stabilize git init
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
@@ -30,36 +30,36 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
|
||||
// Create "remote" repository
|
||||
remoteDir := filepath.Join(tempDir, "remote")
|
||||
if err := os.MkdirAll(remoteDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create remote dir: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Initialize remote git repo
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
// Create "clone1" repository (Agent A)
|
||||
clone1Dir := filepath.Join(tempDir, "clone1")
|
||||
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
||||
configureGit(t, clone1Dir)
|
||||
|
||||
|
||||
// Initialize beads in clone1
|
||||
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
|
||||
if err := os.MkdirAll(clone1BeadsDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
|
||||
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
|
||||
clone1Store := newTestStore(t, clone1DBPath)
|
||||
defer clone1Store.Close()
|
||||
|
||||
|
||||
ctx := context.Background()
|
||||
if err := clone1Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Create an open issue in clone1
|
||||
issue := &types.Issue{
|
||||
Title: "Test daemon auto-import",
|
||||
@@ -73,39 +73,39 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
}
|
||||
issueID := issue.ID
|
||||
|
||||
|
||||
// Export to JSONL
|
||||
jsonlPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
|
||||
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Commit and push from clone1
|
||||
runGitCmd(t, clone1Dir, "add", ".beads")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Add test issue")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Create "clone2" repository (Agent B)
|
||||
clone2Dir := filepath.Join(tempDir, "clone2")
|
||||
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
||||
configureGit(t, clone2Dir)
|
||||
|
||||
|
||||
// Initialize empty database in clone2
|
||||
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
||||
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
|
||||
clone2Store := newTestStore(t, clone2DBPath)
|
||||
defer clone2Store.Close()
|
||||
|
||||
|
||||
if err := clone2Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Import initial JSONL in clone2
|
||||
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
|
||||
if err := importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath); err != nil {
|
||||
t.Fatalf("Failed to import: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify issue exists in clone2
|
||||
initialIssue, err := clone2Store.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
@@ -114,27 +114,27 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
if initialIssue.Status != types.StatusOpen {
|
||||
t.Errorf("Expected status open, got %s", initialIssue.Status)
|
||||
}
|
||||
|
||||
|
||||
// NOW THE CRITICAL TEST: Agent A closes the issue and pushes
|
||||
t.Run("DaemonAutoImportsAfterGitPull", func(t *testing.T) {
|
||||
// Agent A closes the issue
|
||||
if err := clone1Store.CloseIssue(ctx, issueID, "Completed", "agent-a"); err != nil {
|
||||
t.Fatalf("Failed to close issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Agent A exports to JSONL
|
||||
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export after close: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Agent A commits and pushes
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Close issue")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Agent B does git pull (updates JSONL on disk)
|
||||
runGitCmd(t, clone2Dir, "pull")
|
||||
|
||||
|
||||
// Wait for filesystem to settle after git operations
|
||||
// Windows has lower filesystem timestamp precision (typically 100ms)
|
||||
// and file I/O may be slower, so we need a longer delay
|
||||
@@ -143,23 +143,23 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
} else {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
|
||||
// Start daemon server in clone2
|
||||
socketPath := filepath.Join(clone2BeadsDir, "bd.sock")
|
||||
os.Remove(socketPath) // Ensure clean state
|
||||
|
||||
|
||||
server := rpc.NewServer(socketPath, clone2Store, clone2Dir, clone2DBPath)
|
||||
|
||||
|
||||
// Start server in background
|
||||
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||
defer serverCancel()
|
||||
|
||||
|
||||
go func() {
|
||||
if err := server.Start(serverCtx); err != nil {
|
||||
t.Logf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
// Wait for server to be ready
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -167,7 +167,7 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Simulate a daemon request (like "bd show <issue>")
|
||||
// The daemon should auto-import the updated JSONL before responding
|
||||
client, err := rpc.TryConnect(socketPath)
|
||||
@@ -178,15 +178,15 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
t.Fatal("Client is nil")
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
|
||||
client.SetDatabasePath(clone2DBPath) // Route to correct database
|
||||
|
||||
|
||||
// Make a request that triggers auto-import check
|
||||
resp, err := client.Execute("show", map[string]string{"id": issueID})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get issue from daemon: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Parse response
|
||||
var issue types.Issue
|
||||
issueJSON, err := json.Marshal(resp.Data)
|
||||
@@ -196,25 +196,25 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
if err := json.Unmarshal(issueJSON, &issue); err != nil {
|
||||
t.Fatalf("Failed to unmarshal issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
status := issue.Status
|
||||
|
||||
|
||||
// CRITICAL ASSERTION: Daemon should return CLOSED status from JSONL
|
||||
// not stale OPEN status from SQLite
|
||||
if status != types.StatusClosed {
|
||||
t.Errorf("DAEMON AUTO-IMPORT FAILED: Expected status 'closed' but got '%s'", status)
|
||||
t.Errorf("This means daemon is serving stale SQLite data instead of auto-importing JSONL")
|
||||
|
||||
|
||||
// Double-check JSONL has correct status
|
||||
jsonlData, _ := os.ReadFile(clone2JSONLPath)
|
||||
t.Logf("JSONL content: %s", string(jsonlData))
|
||||
|
||||
|
||||
// Double-check what's in SQLite
|
||||
directIssue, _ := clone2Store.GetIssue(ctx, issueID)
|
||||
t.Logf("SQLite status: %s", directIssue.Status)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Additional test: Verify multiple rapid changes
|
||||
t.Run("DaemonHandlesRapidUpdates", func(t *testing.T) {
|
||||
// Agent A updates priority
|
||||
@@ -223,18 +223,18 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
}, "agent-a"); err != nil {
|
||||
t.Fatalf("Failed to update priority: %v", err)
|
||||
}
|
||||
|
||||
|
||||
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Update priority")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Agent B pulls
|
||||
runGitCmd(t, clone2Dir, "pull")
|
||||
|
||||
|
||||
// Query via daemon - should see priority 0
|
||||
// (Execute forces auto-import synchronously)
|
||||
socketPath := filepath.Join(clone2BeadsDir, "bd.sock")
|
||||
@@ -243,18 +243,18 @@ func TestDaemonAutoImportAfterGitPull(t *testing.T) {
|
||||
t.Fatalf("Failed to connect to daemon: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
|
||||
client.SetDatabasePath(clone2DBPath) // Route to correct database
|
||||
|
||||
|
||||
resp, err := client.Execute("show", map[string]string{"id": issueID})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get issue from daemon: %v", err)
|
||||
}
|
||||
|
||||
|
||||
var issue types.Issue
|
||||
issueJSON, _ := json.Marshal(resp.Data)
|
||||
json.Unmarshal(issueJSON, &issue)
|
||||
|
||||
|
||||
if issue.Priority != 0 {
|
||||
t.Errorf("Expected priority 0 after auto-import, got %d", issue.Priority)
|
||||
}
|
||||
@@ -273,23 +273,23 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
|
||||
// Setup remote and two clones
|
||||
remoteDir := filepath.Join(tempDir, "remote")
|
||||
os.MkdirAll(remoteDir, 0750)
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
clone1Dir := filepath.Join(tempDir, "clone1")
|
||||
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
||||
configureGit(t, clone1Dir)
|
||||
|
||||
|
||||
clone2Dir := filepath.Join(tempDir, "clone2")
|
||||
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
||||
configureGit(t, clone2Dir)
|
||||
|
||||
|
||||
// Initialize beads in both clones
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
// Clone1 setup
|
||||
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
|
||||
os.MkdirAll(clone1BeadsDir, 0750)
|
||||
@@ -297,7 +297,7 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
|
||||
clone1Store := newTestStore(t, clone1DBPath)
|
||||
defer clone1Store.Close()
|
||||
clone1Store.SetMetadata(ctx, "issue_prefix", "test")
|
||||
|
||||
|
||||
// Clone2 setup
|
||||
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
||||
os.MkdirAll(clone2BeadsDir, 0750)
|
||||
@@ -305,7 +305,7 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
|
||||
clone2Store := newTestStore(t, clone2DBPath)
|
||||
defer clone2Store.Close()
|
||||
clone2Store.SetMetadata(ctx, "issue_prefix", "test")
|
||||
|
||||
|
||||
// Agent A creates issue and pushes
|
||||
issue2 := &types.Issue{
|
||||
Title: "Shared issue",
|
||||
@@ -317,18 +317,18 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
|
||||
}
|
||||
clone1Store.CreateIssue(ctx, issue2, "agent-a")
|
||||
issueID := issue2.ID
|
||||
|
||||
|
||||
clone1JSONLPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
|
||||
exportIssuesToJSONL(ctx, clone1Store, clone1JSONLPath)
|
||||
runGitCmd(t, clone1Dir, "add", ".beads")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Initial issue")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Agent B pulls and imports
|
||||
runGitCmd(t, clone2Dir, "pull")
|
||||
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
|
||||
importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath)
|
||||
|
||||
|
||||
// THE CORRUPTION SCENARIO:
|
||||
// 1. Agent A closes the issue and pushes
|
||||
clone1Store.CloseIssue(ctx, issueID, "Done", "agent-a")
|
||||
@@ -336,31 +336,31 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Close issue")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// 2. Agent B does git pull (JSONL updated on disk)
|
||||
runGitCmd(t, clone2Dir, "pull")
|
||||
|
||||
|
||||
// Wait for filesystem to settle after git operations
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
|
||||
// 3. Agent B daemon exports STALE data (if auto-import doesn't work)
|
||||
// This would overwrite Agent A's closure with old "open" status
|
||||
|
||||
|
||||
// Start daemon in clone2
|
||||
socketPath := filepath.Join(clone2BeadsDir, "bd.sock")
|
||||
os.Remove(socketPath)
|
||||
|
||||
|
||||
server := rpc.NewServer(socketPath, clone2Store, clone2Dir, clone2DBPath)
|
||||
|
||||
|
||||
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||
defer serverCancel()
|
||||
|
||||
|
||||
go func() {
|
||||
if err := server.Start(serverCtx); err != nil {
|
||||
t.Logf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
// Wait for server
|
||||
for i := 0; i < 50; i++ {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -368,43 +368,43 @@ func TestDaemonAutoImportDataCorruption(t *testing.T) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Trigger daemon operation (should auto-import first)
|
||||
client, err := rpc.TryConnect(socketPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
|
||||
client.SetDatabasePath(clone2DBPath)
|
||||
|
||||
|
||||
resp, err := client.Execute("show", map[string]string{"id": issueID})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
var issue types.Issue
|
||||
issueJSON, _ := json.Marshal(resp.Data)
|
||||
json.Unmarshal(issueJSON, &issue)
|
||||
|
||||
|
||||
status := issue.Status
|
||||
|
||||
|
||||
// If daemon didn't auto-import, this would be "open" (stale)
|
||||
// With the fix, it should be "closed" (fresh from JSONL)
|
||||
if status != types.StatusClosed {
|
||||
t.Errorf("DATA CORRUPTION DETECTED: Daemon has stale status '%s' instead of 'closed'", status)
|
||||
t.Error("If daemon exports this stale data, it will overwrite Agent A's changes on next push")
|
||||
}
|
||||
|
||||
|
||||
// Now simulate daemon export (which happens on timer)
|
||||
// With auto-import working, this export should have fresh data
|
||||
exportIssuesToJSONL(ctx, clone2Store, clone2JSONLPath)
|
||||
|
||||
|
||||
// Read back JSONL to verify it has correct status
|
||||
data, _ := os.ReadFile(clone2JSONLPath)
|
||||
var exportedIssue types.Issue
|
||||
json.NewDecoder(bytes.NewReader(data)).Decode(&exportedIssue)
|
||||
|
||||
|
||||
if exportedIssue.Status != types.StatusClosed {
|
||||
t.Errorf("CORRUPTION: Exported JSONL has wrong status '%s', would overwrite remote", exportedIssue.Status)
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@ func TestSyncBranchCommitAndPush_NotConfigured(t *testing.T) {
|
||||
|
||||
// Create test issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
@@ -122,12 +122,12 @@ func TestSyncBranchCommitAndPush_Success(t *testing.T) {
|
||||
|
||||
// Create test issue
|
||||
issue := &types.Issue{
|
||||
Title: "Test sync branch issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "Test sync branch issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
@@ -228,12 +228,12 @@ func TestSyncBranchCommitAndPush_EnvOverridesDB(t *testing.T) {
|
||||
|
||||
// Create test issue and export JSONL
|
||||
issue := &types.Issue{
|
||||
Title: "Env override issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "Env override issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
@@ -303,12 +303,12 @@ func TestSyncBranchCommitAndPush_NoChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
@@ -380,12 +380,12 @@ func TestSyncBranchCommitAndPush_WorktreeHealthCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
issue := &types.Issue{
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "Test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
@@ -497,7 +497,7 @@ func TestSyncBranchPull_Success(t *testing.T) {
|
||||
if err := os.MkdirAll(remoteDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create remote dir: %v", err)
|
||||
}
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
// Create clone1 (will push changes)
|
||||
clone1Dir := filepath.Join(tmpDir, "clone1")
|
||||
@@ -528,12 +528,12 @@ func TestSyncBranchPull_Success(t *testing.T) {
|
||||
|
||||
// Create issue in clone1
|
||||
issue := &types.Issue{
|
||||
Title: "Test sync pull issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "Test sync pull issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := store1.CreateIssue(ctx, issue, "test"); err != nil {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
@@ -639,7 +639,7 @@ func TestSyncBranchIntegration_EndToEnd(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
remoteDir := filepath.Join(tmpDir, "remote")
|
||||
os.MkdirAll(remoteDir, 0755)
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
// Clone1: Agent A
|
||||
clone1Dir := filepath.Join(tmpDir, "clone1")
|
||||
@@ -660,12 +660,12 @@ func TestSyncBranchIntegration_EndToEnd(t *testing.T) {
|
||||
|
||||
// Agent A creates issue
|
||||
issue := &types.Issue{
|
||||
Title: "E2E test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Title: "E2E test issue",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
store1.CreateIssue(ctx, issue, "agent-a")
|
||||
issueID := issue.ID
|
||||
@@ -914,7 +914,7 @@ func TestSyncBranchMultipleConcurrentClones(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
remoteDir := filepath.Join(tmpDir, "remote")
|
||||
os.MkdirAll(remoteDir, 0755)
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
syncBranch := "beads-sync"
|
||||
|
||||
@@ -1454,7 +1454,7 @@ func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) {
|
||||
|
||||
// Create a "remote" bare repository
|
||||
remoteDir := t.TempDir()
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
// Create first clone (simulates another developer's clone)
|
||||
clone1Dir := t.TempDir()
|
||||
@@ -1524,7 +1524,7 @@ func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) {
|
||||
|
||||
// Now try to push from worktree - this should trigger the fetch-rebase-retry logic
|
||||
// because the remote has commits that the local worktree doesn't have
|
||||
err := gitPushFromWorktree(ctx, worktreePath, "beads-sync")
|
||||
err := gitPushFromWorktree(ctx, worktreePath, "beads-sync", "")
|
||||
if err != nil {
|
||||
t.Fatalf("gitPushFromWorktree failed: %v (expected fetch-rebase-retry to succeed)", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -897,11 +898,7 @@ func setupDaemonTestEnvForDelete(t *testing.T) (context.Context, context.CancelF
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
|
||||
log := daemonLogger{
|
||||
logFunc: func(format string, args ...interface{}) {
|
||||
t.Logf("[daemon] "+format, args...)
|
||||
},
|
||||
}
|
||||
log := daemonLogger{logger: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo}))}
|
||||
|
||||
server, _, err := startRPCServer(ctx, socketPath, testStore, tmpDir, testDBPath, log)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,11 @@ func mkTmpDirInTmp(t *testing.T, prefix string) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("/tmp", prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
// Fallback for platforms without /tmp (e.g. Windows).
|
||||
dir, err = os.MkdirTemp("", prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
|
||||
125
cmd/bd/doctor_repair_chaos_test.go
Normal file
125
cmd/bd/doctor_repair_chaos_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
//go:build chaos
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoctorRepair_CorruptDatabase_NotADatabase_RebuildFromJSONL(t *testing.T) {
|
||||
bdExe := buildBDForTest(t)
|
||||
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-*")
|
||||
dbPath := filepath.Join(ws, ".beads", "beads.db")
|
||||
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
|
||||
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
|
||||
t.Fatalf("bd init failed: %v", err)
|
||||
}
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
|
||||
t.Fatalf("bd create failed: %v", err)
|
||||
}
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
|
||||
t.Fatalf("bd export failed: %v", err)
|
||||
}
|
||||
|
||||
// Make the DB unreadable.
|
||||
if err := os.WriteFile(dbPath, []byte("not a database"), 0644); err != nil {
|
||||
t.Fatalf("corrupt db: %v", err)
|
||||
}
|
||||
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
|
||||
t.Fatalf("bd doctor --fix failed: %v", err)
|
||||
}
|
||||
|
||||
if out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor"); err != nil {
|
||||
t.Fatalf("bd doctor after fix failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRepair_CorruptDatabase_NoJSONL_FixFails(t *testing.T) {
|
||||
bdExe := buildBDForTest(t)
|
||||
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-nojsonl-*")
|
||||
dbPath := filepath.Join(ws, ".beads", "beads.db")
|
||||
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
|
||||
t.Fatalf("bd init failed: %v", err)
|
||||
}
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
|
||||
t.Fatalf("bd create failed: %v", err)
|
||||
}
|
||||
|
||||
// Some workflows keep JSONL in sync automatically; force it to be missing.
|
||||
_ = os.Remove(filepath.Join(ws, ".beads", "issues.jsonl"))
|
||||
_ = os.Remove(filepath.Join(ws, ".beads", "beads.jsonl"))
|
||||
|
||||
// Corrupt without providing JSONL source-of-truth.
|
||||
if err := os.Truncate(dbPath, 64); err != nil {
|
||||
t.Fatalf("truncate db: %v", err)
|
||||
}
|
||||
|
||||
out, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes")
|
||||
if err == nil {
|
||||
t.Fatalf("expected bd doctor --fix to fail without JSONL")
|
||||
}
|
||||
if !strings.Contains(out, "cannot auto-recover") {
|
||||
t.Fatalf("expected auto-recover error, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorRepair_CorruptDatabase_BacksUpSidecars(t *testing.T) {
|
||||
bdExe := buildBDForTest(t)
|
||||
ws := mkTmpDirInTmp(t, "bd-doctor-chaos-sidecars-*")
|
||||
dbPath := filepath.Join(ws, ".beads", "beads.db")
|
||||
jsonlPath := filepath.Join(ws, ".beads", "issues.jsonl")
|
||||
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "init", "--prefix", "chaos", "--quiet"); err != nil {
|
||||
t.Fatalf("bd init failed: %v", err)
|
||||
}
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "create", "Chaos issue", "-p", "1"); err != nil {
|
||||
t.Fatalf("bd create failed: %v", err)
|
||||
}
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "export", "-o", jsonlPath, "--force"); err != nil {
|
||||
t.Fatalf("bd export failed: %v", err)
|
||||
}
|
||||
|
||||
// Ensure sidecars exist so we can verify they get moved with the backup.
|
||||
for _, suffix := range []string{"-wal", "-shm", "-journal"} {
|
||||
if err := os.WriteFile(dbPath+suffix, []byte("x"), 0644); err != nil {
|
||||
t.Fatalf("write sidecar %s: %v", suffix, err)
|
||||
}
|
||||
}
|
||||
if err := os.Truncate(dbPath, 64); err != nil {
|
||||
t.Fatalf("truncate db: %v", err)
|
||||
}
|
||||
|
||||
if _, err := runBDSideDB(t, bdExe, ws, dbPath, "doctor", "--fix", "--yes"); err != nil {
|
||||
t.Fatalf("bd doctor --fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify a backup exists, and at least one sidecar got moved.
|
||||
entries, err := os.ReadDir(filepath.Join(ws, ".beads"))
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
var backup string
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".corrupt.backup.db") {
|
||||
backup = filepath.Join(ws, ".beads", e.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if backup == "" {
|
||||
t.Fatalf("expected backup db in .beads, found none")
|
||||
}
|
||||
|
||||
wal := backup + "-wal"
|
||||
if _, err := os.Stat(wal); err != nil {
|
||||
// At minimum, the backup DB itself should exist; sidecar backup is best-effort.
|
||||
if _, err2 := os.Stat(backup); err2 != nil {
|
||||
t.Fatalf("backup db missing: %v", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,11 @@ func mkTmpDirInTmp(t *testing.T, prefix string) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("/tmp", prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
// Fallback for platforms without /tmp (e.g. Windows).
|
||||
dir, err = os.MkdirTemp("", prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||
return dir
|
||||
|
||||
@@ -26,36 +26,36 @@ func TestGitPullSyncIntegration(t *testing.T) {
|
||||
|
||||
// Create temp directory for test repositories
|
||||
tempDir := t.TempDir()
|
||||
|
||||
|
||||
// Create "remote" repository
|
||||
remoteDir := filepath.Join(tempDir, "remote")
|
||||
if err := os.MkdirAll(remoteDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create remote dir: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Initialize remote git repo
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
|
||||
runGitCmd(t, remoteDir, "init", "--bare", "-b", "master")
|
||||
|
||||
// Create "clone1" repository
|
||||
clone1Dir := filepath.Join(tempDir, "clone1")
|
||||
runGitCmd(t, tempDir, "clone", remoteDir, clone1Dir)
|
||||
configureGit(t, clone1Dir)
|
||||
|
||||
|
||||
// Initialize beads in clone1
|
||||
clone1BeadsDir := filepath.Join(clone1Dir, ".beads")
|
||||
if err := os.MkdirAll(clone1BeadsDir, 0750); err != nil {
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
|
||||
|
||||
clone1DBPath := filepath.Join(clone1BeadsDir, "test.db")
|
||||
clone1Store := newTestStore(t, clone1DBPath)
|
||||
defer clone1Store.Close()
|
||||
|
||||
|
||||
ctx := context.Background()
|
||||
if err := clone1Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Create and close an issue in clone1
|
||||
issue := &types.Issue{
|
||||
Title: "Test sync issue",
|
||||
@@ -69,80 +69,80 @@ func TestGitPullSyncIntegration(t *testing.T) {
|
||||
t.Fatalf("Failed to create issue: %v", err)
|
||||
}
|
||||
issueID := issue.ID
|
||||
|
||||
|
||||
// Close the issue
|
||||
if err := clone1Store.CloseIssue(ctx, issueID, "Test completed", "test-user"); err != nil {
|
||||
t.Fatalf("Failed to close issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Export to JSONL
|
||||
jsonlPath := filepath.Join(clone1BeadsDir, "issues.jsonl")
|
||||
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Commit and push from clone1
|
||||
runGitCmd(t, clone1Dir, "add", ".beads")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Add closed issue")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Create "clone2" repository
|
||||
clone2Dir := filepath.Join(tempDir, "clone2")
|
||||
runGitCmd(t, tempDir, "clone", remoteDir, clone2Dir)
|
||||
configureGit(t, clone2Dir)
|
||||
|
||||
|
||||
// Initialize empty database in clone2
|
||||
clone2BeadsDir := filepath.Join(clone2Dir, ".beads")
|
||||
clone2DBPath := filepath.Join(clone2BeadsDir, "test.db")
|
||||
clone2Store := newTestStore(t, clone2DBPath)
|
||||
defer clone2Store.Close()
|
||||
|
||||
|
||||
if err := clone2Store.SetMetadata(ctx, "issue_prefix", "test"); err != nil {
|
||||
t.Fatalf("Failed to set prefix: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Import the existing JSONL (simulating initial sync)
|
||||
clone2JSONLPath := filepath.Join(clone2BeadsDir, "issues.jsonl")
|
||||
if err := importJSONLToStore(ctx, clone2Store, clone2DBPath, clone2JSONLPath); err != nil {
|
||||
t.Fatalf("Failed to import: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify issue exists and is closed
|
||||
verifyIssueClosed(t, clone2Store, issueID)
|
||||
|
||||
|
||||
// Note: We don't commit in clone2 - it stays clean as a read-only consumer
|
||||
|
||||
|
||||
// Now test git pull scenario: Clone1 makes a change (update priority)
|
||||
if err := clone1Store.UpdateIssue(ctx, issueID, map[string]interface{}{
|
||||
"priority": 0,
|
||||
}, "test-user"); err != nil {
|
||||
t.Fatalf("Failed to update issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export after update: %v", err)
|
||||
}
|
||||
|
||||
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Update priority")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Clone2 pulls the change
|
||||
runGitCmd(t, clone2Dir, "pull")
|
||||
|
||||
|
||||
// Test auto-import in non-daemon mode
|
||||
t.Run("NonDaemonAutoImport", func(t *testing.T) {
|
||||
// Use a temporary local store for this test
|
||||
localStore := newTestStore(t, clone2DBPath)
|
||||
defer localStore.Close()
|
||||
|
||||
|
||||
// Manually import to simulate auto-import behavior
|
||||
startTime := time.Now()
|
||||
if err := importJSONLToStore(ctx, localStore, clone2DBPath, clone2JSONLPath); err != nil {
|
||||
t.Fatalf("Failed to auto-import: %v", err)
|
||||
}
|
||||
elapsed := time.Since(startTime)
|
||||
|
||||
|
||||
// Verify priority was updated
|
||||
issue, err := localStore.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
@@ -151,13 +151,13 @@ func TestGitPullSyncIntegration(t *testing.T) {
|
||||
if issue.Priority != 0 {
|
||||
t.Errorf("Expected priority 0 after auto-import, got %d", issue.Priority)
|
||||
}
|
||||
|
||||
|
||||
// Verify performance: import should be fast
|
||||
if elapsed > 100*time.Millisecond {
|
||||
t.Logf("Info: import took %v", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// Test bd sync --import-only command
|
||||
t.Run("BdSyncCommand", func(t *testing.T) {
|
||||
// Make another change in clone1 (change priority back to 1)
|
||||
@@ -166,27 +166,27 @@ func TestGitPullSyncIntegration(t *testing.T) {
|
||||
}, "test-user"); err != nil {
|
||||
t.Fatalf("Failed to update issue: %v", err)
|
||||
}
|
||||
|
||||
|
||||
if err := exportIssuesToJSONL(ctx, clone1Store, jsonlPath); err != nil {
|
||||
t.Fatalf("Failed to export: %v", err)
|
||||
}
|
||||
|
||||
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Update priority")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "master")
|
||||
|
||||
|
||||
// Clone2 pulls
|
||||
runGitCmd(t, clone2Dir, "pull")
|
||||
|
||||
|
||||
// Use a fresh store for import
|
||||
syncStore := newTestStore(t, clone2DBPath)
|
||||
defer syncStore.Close()
|
||||
|
||||
|
||||
// Manually trigger import via in-process equivalent
|
||||
if err := importJSONLToStore(ctx, syncStore, clone2DBPath, clone2JSONLPath); err != nil {
|
||||
t.Fatalf("Failed to import via sync: %v", err)
|
||||
}
|
||||
|
||||
|
||||
// Verify priority was updated back to 1
|
||||
issue, err := syncStore.GetIssue(ctx, issueID)
|
||||
if err != nil {
|
||||
@@ -214,7 +214,7 @@ func configureGit(t *testing.T, dir string) {
|
||||
runGitCmd(t, dir, "config", "user.email", "test@example.com")
|
||||
runGitCmd(t, dir, "config", "user.name", "Test User")
|
||||
runGitCmd(t, dir, "config", "pull.rebase", "false")
|
||||
|
||||
|
||||
// Create .gitignore to prevent test database files from being tracked
|
||||
gitignorePath := filepath.Join(dir, ".gitignore")
|
||||
gitignoreContent := `# Test database files
|
||||
@@ -233,7 +233,7 @@ func exportIssuesToJSONL(ctx context.Context, store *sqlite.SQLiteStorage, jsonl
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// Populate dependencies
|
||||
allDeps, err := store.GetAllDependencyRecords(ctx)
|
||||
if err != nil {
|
||||
@@ -244,20 +244,20 @@ func exportIssuesToJSONL(ctx context.Context, store *sqlite.SQLiteStorage, jsonl
|
||||
labels, _ := store.GetLabels(ctx, issue.ID)
|
||||
issue.Labels = labels
|
||||
}
|
||||
|
||||
|
||||
f, err := os.Create(jsonlPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
|
||||
encoder := json.NewEncoder(f)
|
||||
for _, issue := range issues {
|
||||
if err := encoder.Encode(issue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
// Use the autoimport package's AutoImportIfNewer function
|
||||
// For testing, we'll directly parse and import
|
||||
var issues []*types.Issue
|
||||
@@ -278,7 +278,7 @@ func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath
|
||||
}
|
||||
issues = append(issues, &issue)
|
||||
}
|
||||
|
||||
|
||||
// Import each issue
|
||||
for _, issue := range issues {
|
||||
existing, _ := store.GetIssue(ctx, issue.ID)
|
||||
@@ -298,12 +298,12 @@ func importJSONLToStore(ctx context.Context, store *sqlite.SQLiteStorage, dbPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set last_import_time metadata so staleness check works
|
||||
if err := store.SetMetadata(ctx, "last_import_time", time.Now().Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user