diff --git a/cmd/bd/agent.go b/cmd/bd/agent.go index c11b4dc9..20a1f187 100644 --- a/cmd/bd/agent.go +++ b/cmd/bd/agent.go @@ -11,7 +11,6 @@ import ( "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" - "github.com/steveyegge/beads/internal/utils" ) // Valid agent states for state command @@ -146,10 +145,37 @@ func runAgentState(cmd *cobra.Command, args []string) error { ctx := rootCtx - // Resolve agent ID - if not found, we'll auto-create the agent bead + // Resolve agent ID with routing support - if not found, we'll auto-create the agent bead var agentID string var notFound bool - if daemonClient != nil { + var routedResult *RoutedResult + + // Check if routing is needed (bypass daemon for cross-repo lookups) + if needsRouting(agentArg) || daemonClient == nil { + // Use routed resolution for cross-repo lookups + var err error + routedResult, err = resolveAndGetIssueWithRouting(ctx, store, agentArg) + if err != nil { + if routedResult != nil { + routedResult.Close() + } + // Check if it's a "not found" error + if strings.Contains(err.Error(), "no issue found matching") { + notFound = true + agentID = agentArg // Use the input as the ID for creation + } else { + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + } else if routedResult != nil && routedResult.Issue != nil { + agentID = routedResult.ResolvedID + } else { + if routedResult != nil { + routedResult.Close() + } + notFound = true + agentID = agentArg + } + } else if daemonClient != nil { resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) if err != nil { // Check if it's a "not found" error @@ -164,18 +190,13 @@ func runAgentState(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing response: %w", err) } } - } else { - var err error - agentID, err = utils.ResolvePartialID(ctx, store, agentArg) - if err != nil { - // Check if it's a "not found" error - if strings.Contains(err.Error(), "no issue found matching") { - notFound = true - agentID = agentArg // Use the input as the ID for creation - } else { - return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) - } - } + } + + // Determine which store to use (routed or local) + activeStore := store + if routedResult != nil && routedResult.Routed { + activeStore = routedResult.Store + defer routedResult.Close() } var agent *types.Issue @@ -193,7 +214,7 @@ func runAgentState(cmd *cobra.Command, args []string) error { CreatedBy: actor, } - if daemonClient != nil { + if daemonClient != nil && !needsRouting(agentArg) { createArgs := &rpc.CreateArgs{ ID: agentID, Title: agent.Title, @@ -210,24 +231,27 @@ func runAgentState(cmd *cobra.Command, args []string) error { return fmt.Errorf("parsing create response: %w", err) } } else { - if err := store.CreateIssue(ctx, agent, actor); err != nil { + if err := activeStore.CreateIssue(ctx, agent, actor); err != nil { return fmt.Errorf("failed to auto-create agent bead %s: %w", agentID, err) } // Add role_type and rig labels for filtering if roleType != "" { - if err := store.AddLabel(ctx, agent.ID, "role_type:"+roleType, actor); err != nil { + if err := activeStore.AddLabel(ctx, agent.ID, "role_type:"+roleType, actor); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to add role_type label: %v\n", err) } } if rig != "" { - if err := store.AddLabel(ctx, agent.ID, "rig:"+rig, actor); err != nil { + if err := activeStore.AddLabel(ctx, agent.ID, "rig:"+rig, actor); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to add rig label: %v\n", err) } } } } else { // Get existing agent bead to verify it's an agent - if daemonClient != nil { + if routedResult != nil && routedResult.Issue != nil { + // Already have the issue from routed resolution + agent = routedResult.Issue + } else if daemonClient != nil && !needsRouting(agentArg) { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) if err != nil { return fmt.Errorf("agent bead not found: %s", agentID) @@ -237,7 +261,7 @@ func runAgentState(cmd *cobra.Command, args []string) error { } } else { var err error - agent, err = store.GetIssue(ctx, agentID) + agent, err = activeStore.GetIssue(ctx, agentID) if err != nil || agent == nil { return fmt.Errorf("agent bead not found: %s", agentID) } @@ -251,7 +275,7 @@ func runAgentState(cmd *cobra.Command, args []string) error { // Update state and last_activity updateLastActivity := true - if daemonClient != nil { + if daemonClient != nil && !needsRouting(agentArg) { _, err := daemonClient.Update(&rpc.UpdateArgs{ ID: agentID, AgentState: &state, @@ -265,7 +289,7 @@ func runAgentState(cmd *cobra.Command, args []string) error { "agent_state": state, "last_activity": time.Now(), } - if err := store.UpdateIssue(ctx, agentID, updates, actor); err != nil { + if err := activeStore.UpdateIssue(ctx, agentID, updates, actor); err != nil { return fmt.Errorf("failed to update agent state: %w", err) } } @@ -297,9 +321,29 @@ func runAgentHeartbeat(cmd *cobra.Command, args []string) error { ctx := rootCtx - // Resolve agent ID + // Resolve agent ID with routing support var agentID string - if daemonClient != nil { + var routedResult *RoutedResult + + // Check if routing is needed (bypass daemon for cross-repo lookups) + if needsRouting(agentArg) || daemonClient == nil { + // Use routed resolution for cross-repo lookups + var err error + routedResult, err = resolveAndGetIssueWithRouting(ctx, store, agentArg) + if err != nil { + if routedResult != nil { + routedResult.Close() + } + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if routedResult == nil || routedResult.Issue == nil { + if routedResult != nil { + routedResult.Close() + } + return fmt.Errorf("agent bead not found: %s", agentArg) + } + agentID = routedResult.ResolvedID + } else if daemonClient != nil { resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) if err != nil { return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) @@ -307,17 +351,21 @@ func runAgentHeartbeat(cmd *cobra.Command, args []string) error { if err := json.Unmarshal(resp.Data, &agentID); err != nil { return fmt.Errorf("parsing response: %w", err) } - } else { - var err error - agentID, err = utils.ResolvePartialID(ctx, store, agentArg) - if err != nil { - return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) - } + } + + // Determine which store to use (routed or local) + activeStore := store + if routedResult != nil && routedResult.Routed { + activeStore = routedResult.Store + defer routedResult.Close() } // Get agent bead to verify it's an agent var agent *types.Issue - if daemonClient != nil { + if routedResult != nil && routedResult.Issue != nil { + // Already have the issue from routed resolution + agent = routedResult.Issue + } else if daemonClient != nil && !needsRouting(agentArg) { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) if err != nil { return fmt.Errorf("agent bead not found: %s", agentID) @@ -327,7 +375,7 @@ func runAgentHeartbeat(cmd *cobra.Command, args []string) error { } } else { var err error - agent, err = store.GetIssue(ctx, agentID) + agent, err = activeStore.GetIssue(ctx, agentID) if err != nil || agent == nil { return fmt.Errorf("agent bead not found: %s", agentID) } @@ -340,7 +388,7 @@ func runAgentHeartbeat(cmd *cobra.Command, args []string) error { // Update only last_activity updateLastActivity := true - if daemonClient != nil { + if daemonClient != nil && !needsRouting(agentArg) { _, err := daemonClient.Update(&rpc.UpdateArgs{ ID: agentID, LastActivity: &updateLastActivity, @@ -352,7 +400,7 @@ func runAgentHeartbeat(cmd *cobra.Command, args []string) error { updates := map[string]interface{}{ "last_activity": time.Now(), } - if err := store.UpdateIssue(ctx, agentID, updates, actor); err != nil { + if err := activeStore.UpdateIssue(ctx, agentID, updates, actor); err != nil { return fmt.Errorf("failed to update agent heartbeat: %w", err) } } @@ -381,9 +429,30 @@ func runAgentShow(cmd *cobra.Command, args []string) error { ctx := rootCtx - // Resolve agent ID + // Resolve agent ID with routing support var agentID string - if daemonClient != nil { + var routedResult *RoutedResult + + // Check if routing is needed (bypass daemon for cross-repo lookups) + if needsRouting(agentArg) || daemonClient == nil { + // Use routed resolution for cross-repo lookups + var err error + routedResult, err = resolveAndGetIssueWithRouting(ctx, store, agentArg) + if err != nil { + if routedResult != nil { + routedResult.Close() + } + return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) + } + if routedResult == nil || routedResult.Issue == nil { + if routedResult != nil { + routedResult.Close() + } + return fmt.Errorf("agent bead not found: %s", agentArg) + } + agentID = routedResult.ResolvedID + defer routedResult.Close() + } else if daemonClient != nil { resp, err := daemonClient.ResolveID(&rpc.ResolveIDArgs{ID: agentArg}) if err != nil { return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) @@ -391,17 +460,14 @@ func runAgentShow(cmd *cobra.Command, args []string) error { if err := json.Unmarshal(resp.Data, &agentID); err != nil { return fmt.Errorf("parsing response: %w", err) } - } else { - var err error - agentID, err = utils.ResolvePartialID(ctx, store, agentArg) - if err != nil { - return fmt.Errorf("failed to resolve agent %s: %w", agentArg, err) - } } // Get agent bead var agent *types.Issue - if daemonClient != nil { + if routedResult != nil && routedResult.Issue != nil { + // Already have the issue from routed resolution + agent = routedResult.Issue + } else if daemonClient != nil && !needsRouting(agentArg) { resp, err := daemonClient.Show(&rpc.ShowArgs{ID: agentID}) if err != nil { return fmt.Errorf("agent bead not found: %s", agentID) diff --git a/cmd/bd/agent_routing_test.go b/cmd/bd/agent_routing_test.go new file mode 100644 index 00000000..a1ebdd25 --- /dev/null +++ b/cmd/bd/agent_routing_test.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +// TestAgentStateWithRouting tests that bd agent state respects routes.jsonl +// for cross-repo agent resolution. This is a regression test for the bug where +// bd agent state failed to find agents in routed databases while bd show worked. +func TestAgentStateWithRouting(t *testing.T) { + ctx := context.Background() + + // Create temp directory structure: + // tmpDir/ + // .beads/ + // beads.db (town database) + // routes.jsonl (routing config) + // rig/ + // .beads/ + // beads.db (rig database with agent) + tmpDir := t.TempDir() + + // Create town .beads directory + townBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(townBeadsDir, 0755); err != nil { + t.Fatalf("Failed to create town beads dir: %v", err) + } + + // Create rig .beads directory + rigBeadsDir := filepath.Join(tmpDir, "rig", ".beads") + if err := os.MkdirAll(rigBeadsDir, 0755); err != nil { + t.Fatalf("Failed to create rig beads dir: %v", err) + } + + // Initialize town database using helper (prefix without trailing hyphen) + townDBPath := filepath.Join(townBeadsDir, "beads.db") + townStore := newTestStoreWithPrefix(t, townDBPath, "hq") + + // Initialize rig database using helper (prefix without trailing hyphen) + rigDBPath := filepath.Join(rigBeadsDir, "beads.db") + rigStore := newTestStoreWithPrefix(t, rigDBPath, "gt") + + // Create an agent bead in the rig database + agentBead := &types.Issue{ + ID: "gt-testrig-polecat-test", + Title: "Agent: gt-testrig-polecat-test", + IssueType: types.TypeAgent, + Status: types.StatusOpen, + RoleType: "polecat", + Rig: "testrig", + } + if err := rigStore.CreateIssue(ctx, agentBead, "test"); err != nil { + t.Fatalf("Failed to create agent bead: %v", err) + } + + // Create routes.jsonl in town .beads directory + routesContent := `{"prefix":"gt-","path":"rig"}` + routesPath := filepath.Join(townBeadsDir, "routes.jsonl") + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatalf("Failed to write routes.jsonl: %v", err) + } + + // Set up global state for routing to work + oldDbPath := dbPath + dbPath = townDBPath + t.Cleanup(func() { dbPath = oldDbPath }) + + // Test the routed resolution + result, err := resolveAndGetIssueWithRouting(ctx, townStore, "gt-testrig-polecat-test") + if err != nil { + t.Fatalf("resolveAndGetIssueWithRouting failed: %v", err) + } + if result == nil { + t.Fatal("resolveAndGetIssueWithRouting returned nil result") + } + defer result.Close() + + if result.Issue == nil { + t.Fatal("resolveAndGetIssueWithRouting returned nil issue") + } + + if result.Issue.ID != "gt-testrig-polecat-test" { + t.Errorf("Expected issue ID %q, got %q", "gt-testrig-polecat-test", result.Issue.ID) + } + + if !result.Routed { + t.Error("Expected result.Routed to be true for cross-repo lookup") + } + + if result.Issue.IssueType != types.TypeAgent { + t.Errorf("Expected issue type %q, got %q", types.TypeAgent, result.Issue.IssueType) + } + + t.Logf("Successfully resolved agent %s via routing", result.Issue.ID) +} + +// TestNeedsRoutingFunction tests the needsRouting function +func TestNeedsRoutingFunction(t *testing.T) { + // Without dbPath set, needsRouting should return false + oldDbPath := dbPath + dbPath = "" + t.Cleanup(func() { dbPath = oldDbPath }) + + if needsRouting("any-id") { + t.Error("needsRouting should return false when dbPath is empty") + } +} + +// TestAgentHeartbeatWithRouting tests that bd agent heartbeat respects routes.jsonl +func TestAgentHeartbeatWithRouting(t *testing.T) { + ctx := context.Background() + + tmpDir := t.TempDir() + + // Create town .beads directory + townBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(townBeadsDir, 0755); err != nil { + t.Fatalf("Failed to create town beads dir: %v", err) + } + + // Create rig .beads directory + rigBeadsDir := filepath.Join(tmpDir, "rig", ".beads") + if err := os.MkdirAll(rigBeadsDir, 0755); err != nil { + t.Fatalf("Failed to create rig beads dir: %v", err) + } + + // Initialize databases (prefix without trailing hyphen) + townDBPath := filepath.Join(townBeadsDir, "beads.db") + townStore := newTestStoreWithPrefix(t, townDBPath, "hq") + + rigDBPath := filepath.Join(rigBeadsDir, "beads.db") + rigStore := newTestStoreWithPrefix(t, rigDBPath, "gt") + + // Create an agent bead in the rig database + agentBead := &types.Issue{ + ID: "gt-test-witness", + Title: "Agent: gt-test-witness", + IssueType: types.TypeAgent, + Status: types.StatusOpen, + RoleType: "witness", + Rig: "test", + } + if err := rigStore.CreateIssue(ctx, agentBead, "test"); err != nil { + t.Fatalf("Failed to create agent bead: %v", err) + } + + // Create routes.jsonl + routesContent := `{"prefix":"gt-","path":"rig"}` + routesPath := filepath.Join(townBeadsDir, "routes.jsonl") + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatalf("Failed to write routes.jsonl: %v", err) + } + + // Set up global state + oldDbPath := dbPath + dbPath = townDBPath + t.Cleanup(func() { dbPath = oldDbPath }) + + // Test that we can resolve the agent from the town directory + result, err := resolveAndGetIssueWithRouting(ctx, townStore, "gt-test-witness") + if err != nil { + t.Fatalf("resolveAndGetIssueWithRouting failed: %v", err) + } + if result == nil || result.Issue == nil { + t.Fatal("resolveAndGetIssueWithRouting returned nil") + } + defer result.Close() + + if result.Issue.ID != "gt-test-witness" { + t.Errorf("Expected issue ID %q, got %q", "gt-test-witness", result.Issue.ID) + } + + if !result.Routed { + t.Error("Expected result.Routed to be true") + } + + t.Logf("Successfully resolved agent %s via routing for heartbeat test", result.Issue.ID) +} + +// TestAgentShowWithRouting tests that bd agent show respects routes.jsonl +func TestAgentShowWithRouting(t *testing.T) { + ctx := context.Background() + + tmpDir := t.TempDir() + + // Create town .beads directory + townBeadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(townBeadsDir, 0755); err != nil { + t.Fatalf("Failed to create town beads dir: %v", err) + } + + // Create rig .beads directory + rigBeadsDir := filepath.Join(tmpDir, "rig", ".beads") + if err := os.MkdirAll(rigBeadsDir, 0755); err != nil { + t.Fatalf("Failed to create rig beads dir: %v", err) + } + + // Initialize databases (prefix without trailing hyphen) + townDBPath := filepath.Join(townBeadsDir, "beads.db") + townStore := newTestStoreWithPrefix(t, townDBPath, "hq") + + rigDBPath := filepath.Join(rigBeadsDir, "beads.db") + rigStore := newTestStoreWithPrefix(t, rigDBPath, "gt") + + // Create an agent bead in the rig database + agentBead := &types.Issue{ + ID: "gt-myrig-crew-alice", + Title: "Agent: gt-myrig-crew-alice", + IssueType: types.TypeAgent, + Status: types.StatusOpen, + RoleType: "crew", + Rig: "myrig", + } + if err := rigStore.CreateIssue(ctx, agentBead, "test"); err != nil { + t.Fatalf("Failed to create agent bead: %v", err) + } + + // Create routes.jsonl + routesContent := `{"prefix":"gt-","path":"rig"}` + routesPath := filepath.Join(townBeadsDir, "routes.jsonl") + if err := os.WriteFile(routesPath, []byte(routesContent), 0644); err != nil { + t.Fatalf("Failed to write routes.jsonl: %v", err) + } + + // Set up global state + oldDbPath := dbPath + dbPath = townDBPath + t.Cleanup(func() { dbPath = oldDbPath }) + + // Test that we can resolve the agent from the town directory + result, err := resolveAndGetIssueWithRouting(ctx, townStore, "gt-myrig-crew-alice") + if err != nil { + t.Fatalf("resolveAndGetIssueWithRouting failed: %v", err) + } + if result == nil || result.Issue == nil { + t.Fatal("resolveAndGetIssueWithRouting returned nil") + } + defer result.Close() + + if result.Issue.ID != "gt-myrig-crew-alice" { + t.Errorf("Expected issue ID %q, got %q", "gt-myrig-crew-alice", result.Issue.ID) + } + + if result.Issue.IssueType != types.TypeAgent { + t.Errorf("Expected issue type %q, got %q", types.TypeAgent, result.Issue.IssueType) + } + + t.Logf("Successfully resolved agent %s via routing for show test", result.Issue.ID) +}